From 0c9b8584ee556774c274f22c2199b2daace14717 Mon Sep 17 00:00:00 2001 From: qwreey Date: Tue, 4 Aug 2026 00:07:40 +0900 Subject: [PATCH 01/62] =?UTF-8?q?quad-v2=20=EC=9E=AC=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=B4=88=EA=B8=B0=20=EC=8A=A4=EC=BA=90?= =?UTF-8?q?=ED=8F=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quad(Roblox DOMless UI 렌더러) v2 재작성을 위한 .claude/ 계획 구조를 세우고 핵심 아키텍처 결정을 정리함: - quad v1 / rbvm / tbox / Fusion / Vide / 폐기된 quad2-try 프로토타입 리서치 - Store 책임 분리(base vs provider), process/retract 핸들러 디스패치 모델, Ref 역할, Slot/Tween 설계 방향 등 핵심 결정 확정 - .claude/{base,research,qa-request,archive,feedback}, question.md, README.md 구조 마련 (code-docker/webmanager 패턴 참고) - 루트 CLAUDE.md/HUMAN_TODO.md 작성 Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 49 ++++ .claude/base/architecture.md | 77 +++++ .claude/base/comparison-fusion-vide.md | 63 ++++ .claude/base/lifecycle-pattern.md | 132 +++++++++ .claude/base/quad-v1-architecture.md | 84 ++++++ .claude/base/store-semantics.md | 54 ++++ .claude/question.md | 79 +++++ .claude/research/bind-system-plan.md | 271 ++++++++++++++++++ .../research/existing-instance-bind-plan.md | 53 ++++ .claude/research/module-lifecycle-plan.md | 86 ++++++ .claude/research/purity-and-effects-plan.md | 52 ++++ .claude/research/slot-plan.md | 81 ++++++ .claude/research/tween-plan.md | 91 ++++++ .gitignore | 1 + CLAUDE.md | 108 +++++++ HUMAN_TODO.md | 65 +++++ SAFETY.md | 2 + 17 files changed, 1348 insertions(+) create mode 100644 .claude/README.md create mode 100644 .claude/base/architecture.md create mode 100644 .claude/base/comparison-fusion-vide.md create mode 100644 .claude/base/lifecycle-pattern.md create mode 100644 .claude/base/quad-v1-architecture.md create mode 100644 .claude/base/store-semantics.md create mode 100644 .claude/question.md create mode 100644 .claude/research/bind-system-plan.md create mode 100644 .claude/research/existing-instance-bind-plan.md create mode 100644 .claude/research/module-lifecycle-plan.md create mode 100644 .claude/research/purity-and-effects-plan.md create mode 100644 .claude/research/slot-plan.md create mode 100644 .claude/research/tween-plan.md create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 HUMAN_TODO.md create mode 100644 SAFETY.md diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 0000000..00c54ed --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,49 @@ +# .claude/ — quad-v2 계획/설계 문서 색인 + +이 레포 전체가 quad-v2(재작성) 프로젝트이므로, webmanager류 서브프로젝트 구분 없이 +`.claude/` 바로 아래에 전부 있음. **루트 `CLAUDE.md`가 현재 상태+TODO 색인의 +최종 소스** — 먼저 그걸 보고, 특정 결정의 자세한 근거/논의가 필요할 때만 아래 +개별 문서를 열어볼 것. + +## 폴더 기준 + +| 폴더 | 기준 | +|---|---| +| `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식 | +| `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | +| `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | +| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 — 지금은 비어있음 | +| `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | +| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `research/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | + +`research/`의 문서가 설계 확정되면 `base/`로 승격(또는 구현 착수 시 +`qa-request/`행). 지금은 구현 라운드 전(설계 단계)이라 전부 `base/`/`research/`에만 +있음. + +## `base/` — 결정된 것, 프로젝트 전체 컨텍스트 + +| 문서 | 내용 | +|---|---| +| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) | +| `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선 | +| `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료 | +| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | +| `store-semantics.md` | Store는 부작용 허용이 기본, 별도 State 프리미티브는 안 만듦 | + +## `research/` — 아직 착수 전, 상의 필요 + +| 문서 | 내용 | 우선순위 | +|---|---|---| +| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, quad2-try 리서치 결과. 핵심은 확정, 세부 시그니처만 남음 | 최상 — 다른 모든 설계가 이 위에서 조립됨 | +| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정됨 | 최상 — 확정, 구현 착수 시 API 세부만 조정 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw | 상 — bind-system 확정 후 | +| `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel | 중 — 세부 옵션만 남음 | +| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | 하 — 문서화 성격, 급하지 않음 | +| `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | + +## 참고 + +- **저장소 소유자가 답해야 할 질문 전체 취합**: `.claude/question.md` +- **사람만 할 수 있는 일(로컬 조작/결정)**: 루트 `HUMAN_TODO.md` +- **원본 브레인스토밍(raw chain-of-thought)**: `.claude/initreq/raw-userinput.md`, + `.claude/initreq/req.md` — 위 문서들로 나누기 전의 원본, 참고용 백업이니 그대로 둘 것 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md new file mode 100644 index 0000000..4a23973 --- /dev/null +++ b/.claude/base/architecture.md @@ -0,0 +1,77 @@ +# quad-v2 전체 아키텍처 (현재 상태 요약) + +**상태**: base — 횡단 결정의 최종 상태 요약. 특정 기능 plan이 아니라 프로젝트 +전체에 걸친 결정이라 완료 개념 없음. 근거가 된 원본 브레인스토밍은 +`.claude/initreq/raw-userinput.md`(안 옮기고 그대로 둠 — 이 문서들로 나누기 전의 +raw chain-of-thought 백업 역할). 현재 v1 구조는 `base/quad-v1-architecture.md`, +비교 리서치는 `base/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-필드 + 테이블을 계속 쌓는 방식, `base/quad-v1-architecture.md` 참고)은 폐기. + store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — + 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. +4. **PA님 스타일 DI 키 계속 지원**: `[Attribute "Name"]`, `[Tag ""] = true` 같은 + 특수 바인드 키. Tag는 `retract`(구 cleanup, `base/lifecycle-pattern.md` 참고)가 + 내장되어 store 컴퓨티드 바인드도 가능해야 함. +5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/ + `Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유. + 네임스페이싱 문제는 있지만(`.claude/question.md` 참고) 별도 네임스페이스 + 개념을 추가하면 라이브러리 복잡도가 너무 올라간다고 판단 — 당장은 + TagService 그대로 사용. **대신 Ref가 도입됨** — 단 Ref의 용도는 "id로 조회"가 + 아니라 "외부에서 이미 관리되고 있는 instance를 quad로 점진적으로 마이그레이션/ + 래핑하기 위해 직접 참조를 얻는 것"(`research/bind-system-plan.md`의 Ref 절 + 참고) — 둘을 혼동하지 말 것. +6. **함수지향 디폴트, `:` 체이닝은 예외적으로만.** 스토어 바인드처럼 체인이 정말 + 편한 경우만 `:` 사용, 나머지는 외부 함수가 인스턴스를 인자로 받는 모양. +7. **Style(Default) 시스템 폐기.** Roblox 자체 스타일시트를 쓰는 게 낫다고 판단. + 대신 modifier(spread되는 값, `...`으로 풀리는 것)를 지향 — 함수형 modifier가 + store 바인드를 받을 수도 있음. +8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""` + 같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로 + 구현(`research/bind-system-plan.md`). +9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher, + 실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua`에 + 연결 안 된 죽은 코드였음, `base/quad-v1-architecture.md` 참고)은 렌더 + 라이브러리 범위 밖으로 판단. 스토리북 구현체(https://ui-labs.luau.page/docs/getstarted)가 + 이미 존재하므로 대체. +10. **lang 모듈 미구현, 분리.** 로케일/문자열 처리는 리액터블 라이브러리와 + 별개 라이브러리로 존재해야 함(v1 `lang.lua`의 전역 스코프 버그 등은 + `base/quad-v1-architecture.md` 참고 — 애초에 반면교사). +11. **커스텀 Signal 클래스 미구현.** 콜백 정도로 이벤트 바인드 뒤에 함수를 + 넣는 것만으로 충분하다고 판단(단, `base/lifecycle-pattern.md`의 rbvm 리서치 + 결과 rbvm의 커스텀 Signal이 실제로는 재사용 가능해 보여서 상충 — 열린 질문으로 + `.claude/question.md`에 있음). +12. **멀티 타겟(pluggable 백엔드) — 특히 GTK 지원까지 염두.** Roblox 전용 렌더 + 기술(react.lua, Fusion)은 결국 외부 개발자 유인이 없어 발전이 더딜 거라는 + 문제의식. 결과적으로 `plug/roblox`, `plug/base` 정도로 나뉠 전망 — base가 + 가상돔 없이도 프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, + 실제 Roblox 구현은 `quad-roblox` 격 서브패키지가 담당. +13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox + 프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()` + 추가. +14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동 + init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고, + `InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를 + 주는 방식. + +## 아직 미정 (research/로 분리됨) + +바인드 시스템 디스패치, Slot 설계 세부, Tween 플러깅, 모듈 라이프사이클/누가 +Store를 구현하는가, 순수함수 범위, 이미 생성된 인스턴스에 대한 바인드 — +`.claude/research/` 각 문서 참고, 전체 색인은 `.claude/README.md`. diff --git a/.claude/base/comparison-fusion-vide.md b/.claude/base/comparison-fusion-vide.md new file mode 100644 index 0000000..d47740e --- /dev/null +++ b/.claude/base/comparison-fusion-vide.md @@ -0,0 +1,63 @@ +# Fusion / Vide 아키텍처 비교 — quad-v2 설계 근거 + +**상태**: base — 리서치 스냅샷(참고용 근거 자료), "완료" 개념 없음. quad-v2의 +Store/Slot/Tween/bind-dispatch 설계 결정에 인용되는 원본 비교 자료. + +## 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, 즉시 동기 재평가, 다이아몬드 중복 재평가 미해결(저자 인정) | "Store는 값 자체에 항상 eager 발화, cleanup이 key/value를 먼저 확인" 요구사항은 Vide의 push 모델 + eval-전-cleanup 패턴에 더 가까움. 단 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의 "등록 없이 태그로 인식" 인체공학을 합치되, 우선순위 축은 열린 숫자 공간으로 일반화해야 함(`research/bind-system-plan.md`). | + +## 추가로 기록해둘 것 + +- Vide의 암묵적(ambient stack) 의존성 추적 vs Fusion의 명시적 `use()` 축은 + push/pull 축과 독립적인 별개 결정 — quad는 아직 미정(`research/bind-system-plan.md` + 열린 질문 참고). Fusion의 명시적 `use()`는 `checkLifetime` 같은 bind-time + 체크를 가능하게 하는 부수 효과가 있음. +- 두 라이브러리 다 mount 시 단일 소유권 가드가 없다는 것 자체가 quad Slot의 + 차별점이라는 근거로 재사용 가능. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md new file mode 100644 index 0000000..7fb179a --- /dev/null +++ b/.claude/base/lifecycle-pattern.md @@ -0,0 +1,132 @@ +# 라이프사이클 패턴 — rbvm의 `Connected` + GC 관용구 채택 + +**상태**: 결정됨(base) — quad-v2가 채택할 라이프사이클/정리(cleanup) 전략의 원본. +완료 개념 없음, 구현하면서 세부 조정 있을 수 있음. + +## 배경 + +`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. 정리(cleanup)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager) + +rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를 +저장 — 홀더 객체를 아무도 안 들고 있으면 그 private 레코드도 자동으로 사라짐. +즉시 처리하는 예외는 딱 두 가지뿐이었음: (a) 가상 트리의 부모/자식 포인터처럼 +방치하면 죽은 참조를 계속 순회하게 되는 "작고 유계(bounded)"인 것, (b) 네임스페이스 +전체가 통째로 죽을 때의 순서 있는 dispose 훅. **quad-v2 원칙: 기본은 GC 위임, +즉시 정리는 "안 끊으면 죽은 참조를 순회하게 되는 작은 포인터"류에만 국한.** + +### 4. Signal 자체는 커스텀 구현체를 그대로 재사용 가능 + +`signal.luau`의 `Signal`/`Connection` 클래스는 rbvm 프록시 시스템에 의존하지 +않는 범용 이벤트 emitter임 (`Connect`/`Once`/`Wait`/`Fire`/`Destroy`, +`IsInited`/`OnInit`/`OnUninit` 지연 활성화 훅 포함). **단, 사용자 원 메모에는 +"시그널 자체 구현은 아닌듯... 콜백 정도로도 충분"이라고 되어 있어 서로 상충함** +— 아래 열린 질문 참고. + +### 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 관례는 그대로 베끼지 말 것 + (팩토리 함수로 대체 — `research/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`/`research/slot-plan.md`의 "cleanup" 표기는 전부 `retract`로 +갱신됨(이름 변경 근거는 아래). + +## 함수 안에서 만든 옵저버도 GC 대상이 되어야 함 — 범용 "생명 바인드 유틸" 필요 + +사용자 원 메모: "함수 안에서 옵저버 만들어버린 거, 그것도 gc 대상 되어야 +할 텐데, 이건 아래쪽에 생성하는 실 객체에 유저가 바인드 할 수 있게 하는 약간의 +유틸이 있긴 해야 할듯. connect 트릭 그대로 들고 와서 쓰면 될 것 같고. 옵저버는 +canExecute 같은 람다 함수 하나 달게 해서 Connected 상태 보게 하여 실행 안 될 +수 있게 만들어도 될 듯." + +즉, 핸들러가 처리 도중 만든 구독/옵저버 클로저는 그 자체로는 아무것도 자동으로 +GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연결해 참조를 +붙잡아두던 "GC 방지 핫팩"(`base/quad-v1-architecture.md` 참고)과 같은 문제. +**base가 범용 유틸로 제공할 것: 임의의 클로저/구독을 실제 Roblox 객체의 +생명주기에 바인드하는 도구** — 내부적으로 v1/rbvm이 쓰던 "connect 트릭"(어떤 +신호에든 연결해서 참조를 죽을 때까지만 붙잡아두는 것)을 그대로 재사용. 이 +도구로 바인드된 옵저버는 `canExecute: () -> boolean` 같은 predicate 람다를 +가질 수 있어서, `Connected`가 false면 실행 자체를 건너뛸 수 있음(죽은 대상에 +대한 처리 시도 방지, 위 원칙과 직결). + +이건 `research/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을 +이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"(weak-keyed per-instance +저장소), 다른 하나는 "언제까지 실행되어도 되는지"(생명 바인드 + canExecute)를 +다룸. 둘 다 base가 제공하는 범용 유틸로 확정. + +## 이름: `cleanup` → `retract` + +"cleanup"이라는 이름은 부적절하다는 사용자 피드백(완전 소멸 정리로 오인되기 +쉬움) — 실제 의미는 "이전에 적용한 처리를 무른다/멈춘다"이므로 **`retract`** +로 통일. (`revert`, `rescind`도 검토했으나 `retract`가 "이전에 취한 조치를 +철회한다"는 의미로 가장 정확 — `process`/`retract` 쌍으로 자연스럽게 대구를 +이룸.) 모든 문서에서 이 이름으로 갱신. diff --git a/.claude/base/quad-v1-architecture.md b/.claude/base/quad-v1-architecture.md new file mode 100644 index 0000000..3e699b1 --- /dev/null +++ b/.claude/base/quad-v1-architecture.md @@ -0,0 +1,84 @@ +# quad v1 내부 구조 (재작성 이전 기준선) + +**상태**: base — 참고용 스냅샷, "완료" 개념 없음. v1(`.claude/initreq/quad/`)이 +실제로 어떻게 동작하는지 정리한 문서로, v2 설계 시 "이 문제를 안 반복하려면"의 +기준선으로 계속 참조됨. 아래는 리서치 에이전트가 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에서 태그 시스템으로 대체 예정(`base/store-and-tags.md` 참고). + +## 핵심 내부 동작 요약 + +- **`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)` + 우선순위 핸들러 + 레지스트리 (`research/bind-system-plan.md`). +3. 흩어진 "GC 안 되게 참조 붙잡기" 핫팩 → rbvm 스타일 `Connected` 계산 속성 + + 명시적 라이프타임 홀더 (`base/lifecycle-pattern.md`). +4. mount가 여러 책임(부모 부기+파괴+child 레지스트리)을 한 모듈에 다 지는 구조 → + Slot이 child CRUD를 전담, mount는 단일-마운트 강제만 전담 + (`research/slot-plan.md`). +5. tracker.lua, lang.lua 내장 → 둘 다 라이브러리 범위 밖으로 분리(스토리북/ + 외부 로케일 라이브러리에 위임). + +## 열려 있는 확인 사항 + +- `objectListClass.__newIndex`(오타, 항상 미발동)로 문서화된 "GetObjects() 리스트에 + 일괄 프로퍼티 설정" 기능이 실제로 동작하는지 v1에서 재현 테스트 필요 — 동작 안 + 했다면 v2 마이그레이션 가이드에서 "이 기능은 애초에 없었다"고 명시해야 함. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md new file mode 100644 index 0000000..73e0568 --- /dev/null +++ b/.claude/base/store-semantics.md @@ -0,0 +1,54 @@ +# Store 의미론 — 부작용 허용, State 프리미티브 없음 + +**상태**: base — 확정된 설계 결정 두 가지. 원본: `.claude/initreq/raw-userinput.md` +"store는 부작용을 허용함" / "state는 어떻게 구현하는가" 절. + +## Store는 부작용을 허용하는 게 기본 디자인 + +부작용 없이(파라메터 패싱만으로) 쓰는 것도 물론 가능하지만, 라이브러리 차원에서 +막지 않는다. 부작용 유무는 **사용자가 직접 문서화**하는 관례로 둔다 — 라이브러리가 +순수성을 강제하지 않음. + +다만 한 가지는 명확히 구분: **렌더 리턴 위에서 무언가를 observe하는 것은 그냥 +부작용**이다 (`useEffect`와 유사한 것으로 문서화). 이건 "허용되는 부작용"이 아니라 +"당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`research/ +purity-and-effects-plan.md`와 연결됨). + +## 별도 `State` 프리미티브는 만들지 않는다 (기본값) + +클래스 자신이 필요한 state가 있으면 그냥 클래스 안에서 `Store`를 만들면 됨 — +Store는 부분집합으로 쪼개 전달하는 것도 충분히 가능하다고 보기 때문에, 굳이 +"단일 값 저장용" State를 별도로 만들 필요성을 못 느낌. 나누고 싶으면 사용자가 +알아서 나누면 됨(사용자 자유). + +**단서**: 구현하다가 실제로 State가 있는 게 더 편해지는 지점이 나오면 그때 +추가할 수 있음 — 지금은 "필요성이 확인 안 됐다"는 판단이지 "절대 안 만든다"는 +확정이 아님. 구현 라운드에서 이 판단이 바뀌면 이 문서를 갱신할 것. + +## Store 값 설정 문법 — v1 인체공학 유지 (확정) + +**사용자 확인 완료**: Store 값 설정은 `__newindex` 기반(`myStore.key = value`)을 +그대로 유지 — ProfileService 등 Roblox 생태계에서 이미 익숙한 관용구라 바꿀 +이유 없음. 마찬가지로 다음 두 인체공학도 유지: + +- **괄호 생략(paren-less) 구조** — 필요 시 커링(`myStore "key"`처럼 문자열 + 하나로 register를 얻는 v1 스타일)을 계속 허용. +- **`:` 체이닝** — 값을 바꾸는 연산에 한해 체이닝 문법 허용(`base/ + architecture.md`의 "함수지향 디폴트, `:`는 예외적으로만" 원칙과 일치 — 체이닝이 + 자연스러운 곳 중 하나가 바로 이 store 값 변경). + +`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 함께 읽을 +것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 "불변 빌더"를 +흉내낸 것이었지, `:` 체이닝 문법 자체나 커링 문법 자체가 아니었음. v2는 문법 +인체공학(사용자가 좋아하는 부분)은 유지하되 내부 구현(체이닝이 아니라 팩토리 +함수)만 바꾼다. + +## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 연구 필요 + +`useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 +요구가 있음(v1의 `myStore "a,b"` 콤마-조인 문자열 방식은 폐기 대상 — +`base/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). 단, **v1의 +`:Add`/`:With`/`:Tween` 같은 이름 붙은(named) 체이닝 연산은 만들지 않기로 +확정** — 대신 일반 함수를 받아 처리하는 쪽이 일관적이라는 판단. 구체적인 API +모양(`Store.Combine({a,b}, function(a,b) ... end)`류)은 아직 미정 — +`research/bind-system-plan.md` 참고. diff --git a/.claude/question.md b/.claude/question.md new file mode 100644 index 0000000..4dbbff3 --- /dev/null +++ b/.claude/question.md @@ -0,0 +1,79 @@ +# 확인/결정 필요 목록 (전체 취합) + +각 plan 문서에 흩어진 "사용자 확인 필요" 절의 취합본. **막고 있는 항목은 +거의 없음** — 대부분 합리적 기본값/방향을 잡아두고 research 단계에 머물러 +있음. 사용자가 Lua/Roblox 엔진에 대해 깊이 아는 사람이라는 전제로, 우선순위 +높은 것부터 정렬. + +## 확정됨 (2026-08-03 질의응답 라운드, 더 이상 열려있지 않음) + +- **Store 책임 분리**: base가 `LifetimeHandle` 추상화 + store-bind의 재실행 + 로직(`process(inst,k,realv)` 재귀)을 소유, provider는 "언제 죽었다고 + 판단할지"(Roblox `Destroying` 등)만 결정. → `research/module-lifecycle-plan.md`, + `research/bind-system-plan.md` +- **Signal 클래스**: 안 만듦 — 콜백 + `Connected` 계산 속성만. → `base/ + lifecycle-pattern.md` +- **핸들러 계약**: `isHandlable`+`priority`+`process`+`retract` 4종 유지, + tbox식 세분화는 지금 안 함. → `research/bind-system-plan.md` +- **Ref**: 도입하되 용도는 "id 조회 대체"가 아니라 "외부 관리 instance를 + 점진적으로 마이그레이션/래핑하기 위한 직접 참조 획득". Tween 등 어떤 + 핸들러도 대상 획득에 Ref가 필요하지 않음(항상 `inst`를 직접 받음). + → `research/bind-system-plan.md` +- **`retract`(구 cleanup) 호출 시점**: 값 교체 시에만 호출, Destroy 시엔 + 호출 안 함(quad는 자신이 만든 instance의 생명주기 중간에 있지 않으므로 + destroy-time 정리 자체가 불필요/불가능). → `base/lifecycle-pattern.md` +- **핸들러 내부 상태 저장**: base가 범용 weak-keyed per-instance 저장 유틸 + 제공(모든 핸들러 재사용). → `research/bind-system-plan.md`, + `base/lifecycle-pattern.md` +- **Store 값 설정 문법**: `__newindex`(`myStore.key = v`) 유지, 괄호 생략 + 커링/`:` 체이닝 인체공학도 유지 — 바뀌는 건 내부 구현(팩토리 함수)뿐. + → `base/store-semantics.md` +- **Store의 named modifier(`:Add`/`:Mul` 등)**: 안 만듦 — 일반 함수를 받는 + 형태로 통일. → `base/store-semantics.md` + +## 추가 확정됨 (2번째 라운드) + +- **트윈 오버라이드 기본값**: 멈춤(Cancel), 새 트윈은 현재 보간된 값에서 시작. + 나머지 세 동작(오버라이드/삭제후재시작/끝점이동후재시작)은 옵션으로 선택 + 가능. → `research/tween-plan.md` +- **Slot 재마운트 에러**: 즉시 throw. → `research/slot-plan.md` +- **`CreatedRef` 콜백 타이밍**: 생성 시점/마운트 시점 둘 다 옵션으로 지원. + → `research/bind-system-plan.md` +- **여러 store 값 묶기**: `Store.Combine`류 포지셔널 인자 방식과 Vide식 암묵적 + 추적 둘 다 기각 — `:With(...)` + `:Compute(fn)`(fn은 with한 값을 포지셔널 + 인자가 아니라 클로저로 읽음) 방식으로 확정. Unix 파이프에서 영감받은 완전 + 합성 가능한 State 스트림이 이상향이나 기술적 난이도 미확정 — 과거 시도 + (`quad2-try/quad-core`) 리서치 진행 중. → `research/bind-system-plan.md` + +## quad2-try(이전 폐기된 시도) 리서치 완료 — 추가 확정 + +- **OOP 상속/`--&` 커스텀 파서/Slot 스텁은 확인대로 죽은 접근** — 절대 반복 + 금지, Slot은 from-scratch 설계 그대로 진행(재조사 불필요). +- **mutate-vs-`fromState` 긴장 관계**: quad2-try의 `Pipe` copy-on-write + 절충안(유일한 tip일 때만 뮤테이션, 아니면 복사)이 유력 후보로 좁혀짐 — 단 + 소유권/버전 가드를 제대로 설계해야 함(원본은 가드 없이 방치돼 있었음). +- **`Depend(...)` 액션, `:With` 네이밍**은 이전 시도에서도 지향했던 것과 일치 + — 그대로 채택. → `research/bind-system-plan.md` + +## 순수성/이식성, 기존 인스턴스 바인드 — 확인 완료, 낮은 우선순위로 유지 + +- **"순수함수" 문제는 실제로는 "이식성" 문제였음** — 재사용 의도 컴포넌트가 + 전역 store를 직접 참조하면 이식성이 깨짐(단일 페이지용 컴포넌트나 라이브러리 + 내부 전용 공유 상태는 문제 없음). 기술적 강제 안 함, 문서 경고 수준으로 + 확정. → `research/purity-and-effects-plan.md` +- **이미 생성된 인스턴스 재바인드**: 실제 요청한 사용자를 본 적 없지만 + `retract` 인프라가 이미 있어 미래에 자연스럽게 가능해질 여지가 있음 — + "미지원" 확정도, 착수도 안 함, 진짜 열린 가능성으로만 유지. → `research/ + existing-instance-bind-plan.md` + +## 급하지 않음, 여유 있을 때만 + +- 태그 시스템의 네임스페이싱 부재(라이브러리 간 충돌 가능성)를 얼마나 + 심각하게 볼지 — 지금은 "별도 네임스페이스 개념은 복잡도 대비 이득이 적다"는 + 판단으로 보류 중. → `base/architecture.md` 5번 항목. +- Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 실제로 필요한 + 상황이 있는지 — 구현 단계에서 실사례로 재검증. → `research/bind-system-plan.md` + +--- +전체 순서/우선순위는 루트 `CLAUDE.md`가 최종 소스 — 위 표는 힌트일 뿐 그쪽이 +바뀌면 이 문서도 갱신할 것. diff --git a/.claude/research/bind-system-plan.md b/.claude/research/bind-system-plan.md new file mode 100644 index 0000000..bbf67ea --- /dev/null +++ b/.claude/research/bind-system-plan.md @@ -0,0 +1,271 @@ +# Bind 시스템 — pluggable key/value 핸들러 (핵심 모델 확정, 세부 사항만 남음) + +**상태**: research — 핵심 디스패치 모델(`process`/`retract`, 핸들러 4종 계약, +Signal 미채택, Ref 역할)은 사용자 확인 완료로 사실상 확정. 남은 건 세부 +시그니처(dependency array API, `CreatedRef` 모양) 뿐 — 이것들이 정리되면 +`base/`로 승격 예정. 원본: `.claude/initreq/raw-userinput.md` +"key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 +저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `base/quad-v1-architecture.md` +("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 `.claude/initreq/tbox` +(레지스트리)와 Fusion/Vide 비교는 `base/comparison-fusion-vide.md` 참고. + +## 문제 + +v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 +숫자 키(children/style) vs 문자열 키(prop/event) vs `__type` 태그 테이블 +(register/linker/style)을 하드코딩된 if/elseif 체인으로 구분한다. 새 특수 키 +(`[Attribute "X"]`, `[Tag ""]`, `PropertyChangedEvent ""` 등)를 추가하려면 이 +중앙 함수 자체를 고쳐야 한다 — 라이브러리로서 확장 불가능한 구조. + +## 핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것) + +핸들러는 다음 4개를 제공하는 등록 가능한 객체: + +- `isHandlable(key, value): boolean` — 이 핸들러가 이 key/value 쌍을 처리할 + 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — tbox의 type-check/ + constraint-check 분리 원칙(`.claude/initreq/tbox/CLAUDE.md`의 "타입 체크는 + 분기 선택에 쓰이므로 순수해야 함")을 그대로 적용: `isHandlable`은 오직 + "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 유효성 검사는 핸들러가 + 선택된 *이후* 별도 단계에서. +- `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 + action() 우선순위)보다 일반화된 **열린 숫자 공간**으로. +- `process(inst, key, value)` — 실제 처리 수행(아래 "확정된 디스패치 모델" + 절 참고). v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할. +- `retract(inst, key, value)` — 이전 처리를 무르는/멈추는 함수(아래 절, + `base/lifecycle-pattern.md` 참고). 모든 핸들러가 의미 있게 구현할 필요는 + 없음(예: 일반 프로퍼티 핸들러는 보통 no-op). + +디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, +첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 +열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치 +반환, 실패 정보는 클로저로 지연 생성" 패턴을 구현해뒀음(`.claude/initreq/tbox/ +src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들지 말고 매치 +실패 시에만 클로저 호출. + +## 확정된 디스패치 모델: `process(inst, k, v)` / `retract(inst, k, v)` + +**사용자가 직접 준 구체적인 모델 — 이 문서의 이전 초안보다 우선함.** 아래가 +실제로 구현할 모양: + +- 모든 핸들러는 대상 **Instance를 직접, 항상** 받는다. quad는 "인스턴스를 생성하고 + 그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을 + 그 인스턴스에 적용하도록 돕는 역할에 가깝다. 그래서 핸들러가 "나중에 생길 + 대상"을 비동기로 기다릴 필요 자체가 없음(아래 Ref 절 참고 — Ref는 다른 이유로 + 존재). +- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, `isHandlable(k,v)`를 + 만족하는 최상위 핸들러가 실제 처리를 담당. +- 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 + 잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 store이면 그 값을 처리(구독)함. + 이 핸들러 안에서: + 1. 지금 이 처리가 실행되어도 되는지 라이프타임(`Connected`)을 확인 — + 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 + 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 + 그 시점엔 그냥 `Connected`를 보고 무시(no-op). + 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. + 3. **`realv`를 들고 다시 `process(inst, k, realv)`를 재귀 호출** — 이게 바로 + "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 이 문서 이전 + 초안의 결론과 일치. `realv`가 store가 아니라면 자연히 Tween의 store-bind + 핸들러 `isHandlable`을 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 + 세터 등)로 흘러감 — 무한 재귀 걱정 없음. +- **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 + `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 + "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — + 인스턴스/바인드 전체가 Destroy될 때는 `retract`가 호출되지 않음(`base/ + lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). + - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 + 동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. + - `retract`가 실제로 의미 있는 곳: **Tag를 지운다, Attribute 엔트리 자체를 + 지운다, 실행 중인 Tween을 멈춘다** 같은, "값을 새로 셋하는 것"과 + "이전 상태를 명시적으로 되돌리는 것"이 다른 케이스. + - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를 + 한 번 호출해주면 됨. +- **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 + Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는 + weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에 + 멈추거나 끝냄). **base가 이걸 범용 유틸로 제공**(`base.perInstanceState(inst)` + 류, 정확한 이름/모양은 구현 단계에서 확정) — 모든 핸들러가 재사용, 각자 + WeakMap을 새로 만들지 않음. `base/lifecycle-pattern.md`의 "생명 바인드 유틸"과 + 짝을 이루는 유틸. +- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot + 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 + 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. + +## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 + +사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 +재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음." + +**확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 +다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을 +따르되, `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 value)로 +`process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. 이러면 store 값 +자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) 상관없이 동일한 +재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결. + +Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 +필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 +자연스럽게 맞음 — `research/slot-plan.md` 참고. + +## Store가 Store를 저장 가능한가 + +사용자 원 메모: "슬롯을 스토어처럼 생각 가능하다면 이건 가능하다고 봐야하는가? +아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나 +ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로." + +이 문서의 제안: "Store 안의 값이 Store"인 경우도 그냥 하나의 (key,value) 쌍일 +뿐이고, 그 값 타입(Store)을 인식하는 핸들러가 pluggable 레지스트리에 등록되어 +있으면 됨 — 위 "재실행 래핑" 방식과 동일한 메커니즘으로 커버됨. 별도 특수 +케이스 코드 불필요. + +## Ref — 도입 확정, 단 용도는 재정의됨 + +**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(Tween 핸들러도 +`process(inst,k,v)`처럼 항상 대상 Instance를 직접 받으므로 — 위 "확정된 디스패치 +모델" 참고, `research/tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: + +- v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정 + (`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유. +- 하지만 **"라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게"** + 하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로 + 관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을 + `Parent`로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가 + 하는 다양한 용도. +- Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 + 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 + 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). +- **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯 `CreatedRef` + 같은 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: + `[1] = CreatedRef(function(inst) ... end)`) — child와 동일한 층위에서 + `process(inst,k,v)` 디스패치를 그대로 타게 함. 즉 Ref도 pluggable 핸들러 + 레지스트리의 평범한 참가자. +- 코루틴 기반 "채워질 때까지 대기" 지원 여부는 여전히 미정(별도 확인 필요 없이 + 구현 우선순위 낮음 — 필요성이 명확해지면 그때 추가). +- **콜백 호출 시점 확정**: 생성 직후(construction 시점)와 트리 마운트 후(Parent + 세팅 완료) 둘 다 지원 — 옵션으로 선택(`CreatedRef(fn, {phase="created"| + "mounted"})`류, 정확한 API 이름은 구현 단계에서 확정). + +## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양 + +**사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: + +- **암묵적 자동 추적(Vide식 ambient stack)** 기각 — "함수 실행 중과 끝 사이를 + 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔한 방법이 아니라고 판단. +- **명시적 디펜던시 배열 + 포지셔널 인자**(`Store.Combine({a,b}, function(av,bv) + ...)`)도 기각 — 두 가지 이유: (1) 팩토리 함수로 store-bind 처리기를 쉽게 못 + 만들어줌, (2) 여러 팩토리를 체이닝하면 인자 순서가 꼬일 수 있고, 타입 표기도 + 어려워짐. + +**채택 방향**: `:With(...)`로 필요한 의존성을 모으고, 그 뒤 `:Compute(function() +... end)`에서 **`with`한 값을 포지셔널 인자로 받지 않고 클로저로 직접 읽는다** +(정확히 어떤 방식으로 "직접 읽는지"는 아래 열린 질문 — `:fromState` 후보 참고). + +## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 기술적 난이도 미확정 + +**중요한 배경**: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. +이상적으로는 store에서 한 값을 추적(track)하면 "State"가 나오고, 거기에 +`compute`를 적용하면 또 다른 "State"가 나오는 식 — Unix의 `(cat a; cat b) | while +read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최종 목표. `:With`의 +두 번째 인자(`b`)도 다른 `:Compute`의 결과물(State)을 그대로 받을 수 있어야 +이상적. + +**미해결 긴장 관계**: 이걸 구현하는 두 갈래 방식이 있고 어느 쪽이 맞는지 아직 +결정 안 됨: +1. **Compute 체인이 항상 자기 자신을 mutable하게 바꾼다** — 엔지니어링 비용은 + 낮지만, 다른 코드가 나중에 그 체인 뒤에 새 compute를 붙이는(다른 소비자가 + 동일 State에 독립적으로 파생값을 추가하는) 것이 불가능해짐 — 공유/합성이 + 깨짐. +2. **명시적 `State:fromState(state)`류의 비-mutating 생성자** — 합성은 + 안전해지지만 엔지니어링 비용이 더 큼(정확히 얼마나 큰지 미확정). + +이건 `base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 +같은 축의 문제 — 옵션 2가 그 원칙과 더 잘 맞아 보이지만, 실현 가능성 자체가 +아직 검증 안 됨. + +## quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지 + +`.claude/initreq/quad2-try/out/quad-core`에 정확히 이 문제(Unix 파이프 영감의 +State/스트림)를 다뤘던 이전 시도가 있었음. 조사 결과 요약: + +**확인된 죽은 접근 — 절대 반복하지 말 것:** +- **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 기능 구현"이 마지막까지 미완료로 남아있었음 + — **가져올 게 전혀 없음**, `research/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` 메서드를 호출하는 등 실제로 동작 검증된 적이 + 없는 죽은 스크래치 코드였음 — 이 프로토타입은 끝까지 실사용 검증을 통과한 + 적이 없음. + +**건질 만한 것 (인체공학/아이디어만, 코드는 아님):** +- **`store:Pipe(key):Compute(fn)` 같은 왼쪽에서 오른쪽으로 읽히는 파이프 + 문법 자체**는 목표로 유지할 가치가 있음. +- **`Pipe`가 mutate-vs-`fromState` 긴장 관계에 제시한 절충안** — "체이닝된 + `Compute`/`Add`/... 호출은 자신이 액션 리스트의 유일한 '끝(tip)'일 때만 공유 + 배열에 그대로 append(뮤테이션), 이미 다른 코드가 그 지점 이후로 체인을 + 확장해버렸다면 배열을 복사한 뒤 새 `Pipe` 객체를 반환"하는 **copy-on-write + 방식** — 이건 이 문서의 "mutate-in-place vs `fromState`" 긴장을 실제로 + 풀어보려 한 유일한 시도라 **quad-v2에서 제대로 다시 설계해볼 만한 후보**. + 단, 원본은 "내가 지금 유일한 tip인가" 체크에 소유권/버전 관리가 전혀 없어서 + 경쟁 상황에 취약했고 테스트/실사용 검증도 없었음 — **그대로 베끼지 말고, + 같은 아이디어를 소유권 가드를 제대로 넣어 재설계할 것.** +- **`Depend(...)` 액션** — 계산값에는 관여하지 않고 오직 "이 소스가 바뀌면 + 다시 계산하라"는 추가 의존성만 등록하는 값-투명(value-transparent) no-op + 액션. 작지만 깔끔한 아이디어라 이름 그대로 채택할 만함. +- **흥미로운 발견**: 스크래치 파일(`out/asdf`)에 남아있던 더 이전 버전의 + 파이핑 스케치가 정확히 `Pipe(store.background):With(store.transparency, + globalStore.test):Compute(fn)` 모양이었음 — 실제 구현으로 넘어가며 + `:Depend()`+포지셔널 인자로 바뀌었지만, **`:With(...)` 네이밍은 사용자가 + 이번 라운드에서 다시 요청한 것과 정확히 일치** — 우연이 아니라 원래 + 지향점이었던 것으로 보임, `:With` 이름 채택에 힘을 실어줌. + +**종합**: 이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — 다만 Pipe의 +copy-on-write 절충안과 `Depend`/`:With` 네이밍은 quad-v2 설계에 그대로 +살려볼 가치가 있는 아이디어로 남김. + +## 확정된 것 (더 이상 열린 질문 아님) + +- **핸들러 계약**: `isHandlable(k,v)` + `priority` + `process`(구 `bind`) + + `retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 + 안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장). +- **Signal 클래스**: 안 만듦, 콜백 + `Connected` 계산 속성만(`base/ + lifecycle-pattern.md`). +- **Ref**: 도입 확정(위 절 참고), 용도는 "id 기반 조회 대체"가 아니라 "외부 + 관리 instance를 점진적으로 다루기 위한 직접 참조 획득". + +## 남은 열린 질문 (`.claude/question.md`에도 취합) + +- **`:Compute`가 `with`한 값을 정확히 어떻게 읽는가** — 클로저로 원본 store/ + register를 직접 캡처하는 것인지, `:Compute`가 특수한 접근자를 몸체 함수에 + 넘겨주는 것인지 구체 시그니처 미정. +- **mutate-in-place vs `fromState` 긴장 관계** — quad2-try의 copy-on-write + 절충안(위 절)이 유력한 후보로 좁혀짐. 실제 구현 시 "내가 유일한 tip인가" + 판단에 제대로 된 소유권/버전 가드를 설계하는 게 핵심 과제 — 원본처럼 + 가드 없이 가면 안 됨. +- **`CreatedRef`(가칭)의 정확한 함수/옵션 이름** — children 배열에 아이템으로 + 넣는다는 방향과 생성/마운트 두 시점 모두 지원한다는 것은 확정, 정확한 API + 이름만 남음. +- **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 + 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 + 검증 대상). +- Store가 Store를 담는 경우의 실제 소유권(누가 내부 Store를 destroy하는가) — + 이 문서의 "재실행 래핑" 제안이 맞다면 자연히 바깥 Store bind가 내부 Store의 + 라이프타임도 감싸게 될 텐데, 이중 해제(double-dispose) 방지가 필요한지 확인. + 단, `base/lifecycle-pattern.md`의 "destroy 시점엔 아무것도 안 함" 원칙상 이중 + 해제 자체가 걱정할 필요 없는 개념일 수도 있음 — 재검토 필요. diff --git a/.claude/research/existing-instance-bind-plan.md b/.claude/research/existing-instance-bind-plan.md new file mode 100644 index 0000000..c4ad3f7 --- /dev/null +++ b/.claude/research/existing-instance-bind-plan.md @@ -0,0 +1,53 @@ +# 이미 생성된 인스턴스에 대한 바인드 (후순위, UB 또는 마일스톤) + +**상태**: research — 명시적으로 후순위/UB 후보. 원본: +`.claude/initreq/raw-userinput.md` "이미 생성된 객체에 대한 바인드?" 절. + +## 문제 + +이미 생성된 Roblox Instance에 새로운 `{k=v}` 프롭 테이블을 나중에 바인드하는 +걸 허용할지. 허용하려면 이전 바인드를 끊는 처리가 필요한데, cleanup이 +구현되어 있어도 바로 지원하는 건 엔지니어링 비용이 높음. + +## 기울어진 방향 + +**UB로 두거나, 마일스톤(추후 구현)으로 미룬다.** cleanup이 이미 있고 store +바인드도 우선순위 높은 플러그라면 이론적으로는 가능해 보이지만(핸들러 +레지스트리가 이미 "우선순위 스캔 후 bind" 구조라 재바인드도 같은 경로를 타면 +됨), 초기 구현에서 **우선순위를 낮게** 잡아야 함 — 문제 유무가 많을 수 있어서. + +## Default 값과 얽히는 문제 + +Default를 넣으면 더 어려워짐 — Default로 쓰다가 실제 쓰인 값으로 되돌아가는 +케이스를 생각해야 함. Modifier 설계와 맞물려 있는 문제로, 결과적으로 매번 +테이블을 flattening 해야 할 수도 있음 — 그런데 그걸 위해 클론까지 해야 하나? +사용자 스스로도 "약간 애매" 하다고 남김. + +**후보 아이디어(미확정)**: ref로만 다시 바인드 가능한 걸 얻게 하고, ref가 되면 +복사(clone) 모드를 켜야 하나 — 근데 그러면 너무 복잡해질 것 같다는 우려까지만 +기록. 결론 없음. + +## 사용자 확인 결과: 진짜로 모르겠음 — 열린 가능성으로 유지 + +**사용자 확인 완료, 그러나 결론은 "미정 유지".** 실제로 이 기능을 원한다고 +말한 사용자를 본 적은 없지만, 막상 만들어진다면 유용하게 쓸 수 있을 것 같다는 +느낌은 있음. 근거: +- `retract`(구 cleanup)이 이미 존재한다면, store 바인드도 이미 `retract`되는 + 경로가 있는 셈 — 재바인드를 지원하기 위한 인프라가 어느 정도 이미 깔림. +- Modifier를 잘 설계하면 나중에 오버라이드가 자연스럽게 가능해질 수도 있음 — + 미래에 어떤 방법을 생각해낼 여지가 있다는 것. +- **역사적 맥락**: quad는 원래 "script 스니펫"이라고 부를 정도로, react.lua + 같은 당대 대안 대비 압도적으로 쉽고 단순해서 누구나 빠르게 이해해 쓸 수 + 있는 걸 의도적으로 지향한 도구였음. 라이브러리가 지금처럼 몸집이 커지는 + 후속 단계에선 이런 기능성을 충분히 고려할 만함. + +**결론**: v2 초기 스코프에서 제외하되, "미지원"으로 확정 명문화하지는 않음 — +진짜 열린 가능성으로 남겨두고, 실사용 중 필요성이 드러나면 그때 설계. +`base/architecture.md`의 "복사 구현 지양, store 바인드 변경은 전체 변경" +원칙과 긴장 관계에 있다는 점은 여전히 유효 — 나중에 설계할 때 이 원칙과 +어떻게 공존할지부터 다시 볼 것. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 구체적 설계는 완전히 미정 — 실사용 패턴이 쌓이기 전까지는 착수하지 않음. + 급하지 않음. diff --git a/.claude/research/module-lifecycle-plan.md b/.claude/research/module-lifecycle-plan.md new file mode 100644 index 0000000..102cad6 --- /dev/null +++ b/.claude/research/module-lifecycle-plan.md @@ -0,0 +1,86 @@ +# 모듈 라이프사이클 — 프로바이더 패턴, bind/store는 누가 구현하는가 (착수 전) + +**상태**: research — 방향은 있지만 "누가 store를 구현하는가"는 사용자 스스로 +"진짜 애매한 지점"이라고 남긴 미해결 항목. 원본: +`.claude/initreq/raw-userinput.md` "넘버 바인드는 누가 처리?" / "모듈은 스코핑 +되는가" / "pluggable 하다면 해당 플러그를 초기화하는 건 누구 몫?" / "다시 돌아와서… +bind는 누가 어떻게 구현" / "스토어는 누가 구현해…" 절. 확정된 상위 결정은 +`base/architecture.md` 12~14번 항목(멀티 백엔드, 싱글톤 모듈, 팩토리 초기화) +참고 — 이 문서는 그 안의 세부 미해결 사항만 다룸. + +## 넘버 바인드(숫자 프로퍼티 등)는 누가 처리하는가 + +Slot과 맞물려서 잘 생각해서 구현해야 하는 부분. **기울어진 방향**: mount가 +처리하는 게 맞아 보이지만, 그러면 확장성이 있을지가 문제. 결론: **표준 구현체는 +인터페이스만 두고, 실제 구현은 `quad-roblox` 같은 백엔드 서브패키지가 해당 +인터페이스를 구현**. 런타임에 프로바이더로 Roblox를 주입받는 방향(반대로 +"프로바이더로 base를 받는" 게 아니라) — 이유: 여긴 가상돔이 없어서, base +쪽이 "누가 실제로 그려주는지" 모르는 채로 있다가 Roblox 프로바이더를 주입받는 +모양이 더 자연스러워 보임. + +## 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 +슬롯이지만 유일하게 채워질 수 있는 슬롯" — 위의 `research/bind-system-plan.md`가 +말하는 "여러 핸들러가 우선순위로 경쟁"하는 것과는 다른 층위: **핸들러 +레지스트리 자체(그 배후의 실제 bind 구현/백엔드)는 유일해야 하고, 그 안에 +등록되는 개별 핸들러들은 여럿+우선순위 경쟁이 맞는 모양.** + +의존성을 부작용 식으로 주입해서 `quad-roblox` 바인드를 허용케 하는 건 괜찮아 +보임(=`InitRoblox(Module)`가 하는 일이 바로 이 "유일 슬롯 채우기"). + +## Store는 누구 몫인가 — 상당 부분 확정됨 + +**사용자 확인 완료**: base가 `LifetimeHandle` 추상화(생명주기/`Connected` +계산 속성)를 소유하는 게 맞다고 확정. 추가로 명확해진 것 — **store 바인드가 +수행하는 "처리된 값을 다시 `process(inst,k,realv)`로 넘기는" 재실행 로직 +자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 재구현하면 안 +됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → `research/ +bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 제공 로직. + +부수적으로 확인된 것: +- **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은 + 이름 붙은 체이닝 연산(named modifier)은 명시적으로 안 만들기로 확정, 대신 + 일반 함수를 받는 형태로 통일(`base/store-semantics.md` 참고). "너무 verbose한 + 연산들은 오히려 일관성을 해친다"는 게 이유. +- **여러 store 값을 묶어 유연하게 처리하는 방법**(`useEffect`류 dependency + array)은 있으면 좋겠다는 요청 — API 시그니처는 미정, `research/ + bind-system-plan.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` 도입부와 동일 결정. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- **Store 책임 분리(base vs provider)는 확정됨** — 위 절 참고. 남은 건 실제 + 구현 단계에서 base의 `LifetimeHandle`/재실행 유틸 API를 정확히 어떻게 + 노출할지 정도(설계 방향 자체는 더 이상 열려있지 않음). +- 넘버 바인드/프로바이더 인터페이스의 정확한 함수 시그니처(base가 요구하는 + provider 인터페이스 계약)는 아직 미정 — 구현 착수 시 함께 확정. diff --git a/.claude/research/purity-and-effects-plan.md b/.claude/research/purity-and-effects-plan.md new file mode 100644 index 0000000..b8d4e5d --- /dev/null +++ b/.claude/research/purity-and-effects-plan.md @@ -0,0 +1,52 @@ +# 컴포넌트 순수성이 아니라 "이식성" 문제 (재정의됨) + +**상태**: research — 사용자 확인 완료로 문제 자체는 명확해짐, 남은 건 문서화 +강도 정도. 원본: `.claude/initreq/raw-userinput.md` "순수함수에 대한 범위를 +정할 필요가 있음" / "진짜 부작용은 외부에 만들어버린다" 절. + +## 정정: "순수함수 여부"가 아니라 "이식성(portability)" 문제였다 + +**사용자 확인 완료 — 이전 초안의 프레이밍이 부정확했음.** quad는 vdom이 +없으므로 컴포넌트(Class 함수)는 **딱 한 번만 실행**된다. 모든 부작용은 그 +한 번의 실행에서 전부 등록됨 — store에 의해 렌더 함수 안 특정 부분이 다시 +트리거될 순 있지만, 함수 자체가 반복 실행되는 구조가 아님. 이 전제 위에서 +실제로 문제였던 것은 "순수함수냐 아니냐"가 아니라 **컴포넌트가 자신이 받은 +파라미터(store) 대신 전역(global) store를 직접 참조하는 경우의 이식성**이었음. + +### 구체적 문제 상황 + +컴포넌트가 특정 store를 받아서 렌더하도록 설계되어야 하는데, 그렇게 안 하고 +전역 store를 직접 참조해버리는 경우: +- 그 컴포넌트가 **한 게임 안에서 한 번만 쓰이는 존재**(예: 특정 페이지에 해당하는 + 컴포넌트)라면 전혀 문제 없음 — 오히려 그게 자연스러울 수 있음. +- 하지만 **여기저기서 재사용하려고 만들어둔 컴포넌트**가 전역을 건드린다면 + 이식성이 망가짐 — 다른 프로젝트/다른 컨텍스트에 갖다 쓸 수 없게 됨. +- **라이브러리 내부적으로만 쓰는 공유 값**(라이브러리가 의도적으로 내부에서 + 전역 상태를 만들어 쓰는 경우)은 문제 없을 수도 있음 — 이식성 문제는 "재사용을 + 의도한 컴포넌트가 자기가 받은 입력 밖의 것에 은밀히 의존하는가"에 국한됨. + +### 결론: 입력받은 store만 처리하는 함수가 좋은 컴포넌트 + +재사용/이식을 의도하는 컴포넌트는 파라미터로 받은 store만 처리하는 게 +좋다는 게 결론 — 다만 **이건 기술적으로 막을 문제가 아니라 UB로 두고 사용자에게 +경고해야 할 문서화 문제**. 라이브러리가 "전역 참조 금지"를 런타임/타입 +시스템으로 강제하려는 시도는 좋은 접근이 아니라고 명시적으로 판단함(과도한 +엔지니어링, 정당한 유스케이스까지 막을 위험). + +## 문서화 방향 + +- `base/store-semantics.md`("Store는 부작용을 허용하는 게 기본 디자인")와 + 같은 결의 문제 — Store 자체의 부작용 허용 여부와는 별개로, **컴포넌트가 + "자기 입력 밖의 상태"에 의존하면 이식성이 깨진다**는 원칙을 문서에 별도로 + 명시. +- 가이드 문서에 "재사용 가능한 컴포넌트를 만들 땐 store를 파라미터로만 + 받고 전역을 직접 참조하지 말 것 — 페이지/앱 최상위 컴포넌트처럼 애초에 + 재사용 의도가 없다면 상관없음"이라는 원칙과, 그 이유(이식성)를 예시와 함께 + 기술. +- 린트 규칙이나 런타임 경고 같은 기술적 강제는 하지 않음(확정) — 순수 문서 + 수준의 권장. + +## 열린 질문 + +- 문서에 이 원칙을 얼마나 두드러지게(가이드 최상단 vs 각주 수준) 배치할지 — + 급하지 않음, 실제 문서 작성 단계에서 결정. diff --git a/.claude/research/slot-plan.md b/.claude/research/slot-plan.md new file mode 100644 index 0000000..0f630c5 --- /dev/null +++ b/.claude/research/slot-plan.md @@ -0,0 +1,81 @@ +# Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (착수 전) + +**상태**: research — 설계 방향은 상당히 잡혀 있으나 세부(특히 소유권 이전/해제 +시맨틱)는 사용자와 확인 필요. 원본: `.claude/initreq/raw-userinput.md` "slot을 +구현하도록 하기로 했음" 절. Fusion의 `Children` SpecialKey와 Vide의 mount 무가드 +비교는 `base/comparison-fusion-vide.md` 참고 — 결론: **두 라이브러리 어디에도 +이런 엄격한 단일 마운트 가드가 없음, quad의 진짜 개선점.** + +## 개념 + +`add`/`remove`/`clear`/`get`/`set` 등 뮤터블 연산을 지원하는 메타 배열. 실제 +바인드가 일어나면 child로 풀리고, 이 메타 배열에 CRUD를 하면 실제 children이 +적절히 제어됨. + +## 핵심 제약: 소유권 귀속과 단일 마운트 + +Slot에 들어간 요소는 **ownership이 귀속**되며 다른 곳에 마운트할 수 없게 된다. +`isMounted`를 관리해서, **한 인스턴스에 대한 다중 마운팅이 라이브러리 차원에서 +절대 일어나지 않도록 강제**하는 게 v1 대비 핵심 디자인 변화. v1의 `mount()`는 +별다른 강제를 안 했지만(`base/quad-v1-architecture.md`의 mount.lua 분석 참고 — +실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 mount +함수 자체가 이 강제를 담당. + +Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 부기"로만 +구현했고(재사용 가능한 1급 프리미티브가 아님), Vide는 아예 이 개념이 없어서 +같은 target에 두 번 `mount()`하면 조용히 두 개의 독립 루트가 생김 — 둘 다 +반면교사. + +## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨 + +Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 children으로 +들어가지만, 실제 렌더된 instance에서 `GetChildren()`을 직접 하지 않고도 부모가 +생성한 "실제 slot 데이터 테이블"만 다루면 되게 해서 **추상화 수준을 낮은 직접 +바인딩에서 한 단계 떼어냄**(간접화를 통한 추상화). + +## 마운트된 Slot의 재마운트는 즉시 throw (확정) + +**사용자 확인 완료**: 이미 사용된(마운트된) slot을 재마운트하려 하면 **즉시 +`error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 강하게 +실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서 +**글로벌 스코프에서 slot을 쓰는 건 그다지 좋지 않을 수 있음**(재사용/재마운트가 +막히므로). + +## 클래스가 슬롯을 받는 방법 + +"네이밍된 슬롯"이 필요한가에 대한 사용자 자문: 그냥 슬롯 바인드 테이블을 +값으로 넘기면 되는 것 아닌가 — 결국 array처럼 구현된 Store라고 생각하는 게 +편하다는 방향. **기울어진 결론**: 별도 "Named Slot" 개념 없이, store나 +파라미터로 넘기고 그게 그냥 ref처럼 바인드되는 모양. + +## Slot과 Store 바인드의 관계 (`retract` 순서) + +Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`(구 cleanup, +`base/lifecycle-pattern.md` 참고) 핸들러가 필요함 — 한번 넘어간 slot 요소가 +나중에 `retract`되면 삭제되는지, 아니면 "부모의 소유이니 부모가 처리"해야 +하는지 검토 필요. **기울어진 결론**: 부모가 정리 정도만 미리 수행하고 다시 +`process`하면 되므로, 부모에게 위임(자식 slot 요소 자체가 스스로 정리를 +실행하는 게 아니라). + +이건 `research/bind-system-plan.md`의 "Store 바인드는 재실행 래핑" 확정 +모델과 맞물림 — slot이 store 값으로 오면, store 바인드 핸들러가 이전 slot +상태를 `retract`하고 새 slot 상태로 다시 `process`하는 사이클을 돈다는 뜻. +Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는 +추적(구독)도 `research/bind-system-plan.md`가 말하는 "process 함수가 다른 값 +변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 — +Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도 +동일하게 적용. + +## 자식으로 넘기는 클래스 스토어 + +자식에게 내려주는 클래스 스토어는 부모 쪽에서 미리 만들어서 내려보내는 게 +편할 것 같다는 방향 — `store<>` 형태로 구성된 스토어를 만들면 +됨(타입 표기는 러프한 스케치, 실제 문법은 tbox의 명시적 제네릭 적용 패턴 +`f<>(...)` — `.claude/initreq/tbox/CLAUDE.md:40-41` — 참고해서 확정할 것). + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 재마운트 에러 처리는 확정(throw). 남은 건 Slot 안 요소가 `retract`될 때 + "부모가 정리 후 재`process`"가 정말 항상 올바른 기본 동작인지, 아니면 slot + 자체가 일부 자기 정리를 해야 하는 케이스가 있는지 — 구현하면서 실제 사례로 + 재검증 필요. diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md new file mode 100644 index 0000000..7cf60e3 --- /dev/null +++ b/.claude/research/tween-plan.md @@ -0,0 +1,91 @@ +# Tween / 애니메이션 플러깅 (착수 전, 사용자와 상의 필요) + +**상태**: research — 방향은 뚜렷하게 잡혀 있으나(라이브러리가 트윈을 직접 +구현하지 않는다) cleanup 순서/오버라이드 시맨틱은 미확정. 원본: +`.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은 +항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이 +반응 그래프 안에 있는 설계는 명시적 반면교사 — `base/comparison-fusion-vide.md` +"Animation(Tween/Spring) integration — 경고 신호" 절 참고. + +## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 + +렌더 라이브러리가 트윈을 직접 구현하는 건 처음부터 디자인이 잘못된 접근 — +렌더링 엔진(Roblox `TweenService`)의 애니메이션 관리를 전혀 안 따르게 되기 +때문. 대신: + +``` +[Tween(key, tweenData...)] = storeValue +``` + +형태의 **특수 bind key**로 제공. 처음 실행될 때는 그냥 바인드로 필드를 쓰지만, +이후에는 store 값을 핸들해서 바뀔 때마다 트윈을 처리. 아니면 사용자가 직접 +태그를 얻어 관리하게 둠(둘 다 허용 가능한 경로로 열어둘 것). + +**왜 이게 중요한가(Fusion 리서치에서 확인된 근거)**: Fusion은 Tween/Spring을 +`GraphObject`(1급 반응 노드, `timeliness="eager"`)로 만들어서 Computed의 입력으로 +자유롭게 합성 가능하게 했지만, 그 대가로 (1) 매 프레임 틱하는 외부 클럭 +소스(Stopwatch/ExternalTime)를 반응 그래프에 통합해야 했고, (2) eager 재계산 +노드가 무효화/전파 로직과 경쟁하게 됐고, (3) 트윈-입력 간 별도의 교차 lifetime +체크 기계장치가 필요해졌다. quad가 트윈을 특수 bind key로 빼면 이 세 가지 +복잡도를 전부 피할 수 있다 — 대신 트윈된 값이 Computed류의 추가 입력으로 +자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해). + +## 정정: Ref 불필요 — 핸들러는 항상 대상 Instance를 직접 받는다 + +**이전 초안의 전제가 틀렸음.** Tween 핸들러도 `research/bind-system-plan.md`의 +"확정된 디스패치 모델"을 그대로 따르는 store-bind 핸들러 중 하나 — `process(inst, +k, v)`가 항상 대상 Instance(`inst`)를 직접 받으므로, 트윈 대상을 얻기 위해 +Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 핸들러는 "`k`는 +무엇이든, `v`가 Store인 것"을 잡아내는 우선순위 매우 높은 핸들러로 등록되고, +`inst`는 이미 파라미터로 주어짐. (Ref 자체는 도입되지만 전혀 다른 +용도 — `research/bind-system-plan.md`의 Ref 절 참고.) + +## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 + +**스토어 값은 항상 먼저 캐치한다** — 그래야 `retract` 호출이 가능(이름 변경 +근거는 `base/lifecycle-pattern.md`). 이전 트윈을 취소하고 새 트윈을 만드는 게 +맞지만, "취소" 시점의 동작이 여러 갈래로 갈릴 수 있음: + +1. 키 밸류를 받으면, 그로 인해 생성된 트윈을 얻어서 **멈춰버리기**. +2. 트윈 뒤에 **삭제하지 않고 오버라이드**(새 트윈이 이전 트윈의 현재 값에서 + 시작, 이전 트윈 자체는 그대로 재사용/대체). +3. **삭제** 후 새로 시작. +4. 트윈을 **끝 지점으로 옮기고** 새로운 트윈을 시작. + +**확정된 기본값**: **멈춤(Cancel)** — 새 트윈은 현재 보간된 값에서 자연스럽게 +시작. 근거: Roblox `TweenService`의 `:Cancel()`은 프로퍼티를 되돌리지 않고 +그 자리에서 멈추기만 하므로, 새 트윈이 시작될 때 이미 인스턴스 프로퍼티에 +남아있는 현재 값에서 자연스럽게 이어짐 — 대부분의 UI 애니메이션이 기대하는 +동작과 일치. + +이 기본값 외 나머지 세 동작(오버라이드/삭제 후 재시작/끝점 이동 후 재시작)은 +라이브러리가 강제하지 않고, `[Tween(key, tweenData, {onOverride=...})]`처럼 +키 설정으로 사용자가 고를 수 있게 열어둠 — `retract(inst, k, v)`가 이전 +값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에 +생성한 실제 Tween 객체"는 `research/bind-system-plan.md`가 말하는 base 제공 +범용 유틸(`inst`를 키로 하는 weak-keyed per-instance 상태 저장소)에 담아두면 +됨. + +## `retract`는 완전 소멸(Destroy) 시엔 호출되지 않는다 + +`base/lifecycle-pattern.md`의 핵심 원칙: quad는 자신이 만든 Instance를 생명주기 +끝까지 그대로 들고 있는 소유자라, Destroy 이후에 실행해야 할 정리 로직이 없다 +— 오히려 Destroy된 대상에 `:Cancel()`/`:Stop()` 같은 메서드를 호출하면 에러남 +(대상이 죽으면 그 대상에 묶인 Tween도 함께 죽은 상태가 되므로). 따라서 +**`retract`는 "같은 key에 새 값이 들어와 이전 트윈을 갈아치울 때"만 호출되고, +Destroy 시점엔 아무 것도 안 함(라이프타임 `Connected` 체크로 처리 자체를 +멈추는 것만으로 충분).** + +## 네임스페이스드 객체 (성능상 이유로 보류) + +트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 — +TagService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페이스가 따로 있을 +필요가 있는지는 미정(단, 위 정정으로 이 절 자체의 필요성이 낮아짐 — 핸들러가 +이미 대상을 직접 받으므로 "나중에 이름으로 찾아서 트윈"할 필요 자체가 잘 +없을 수 있음). + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 기본값(Cancel)은 확정됨. 남은 건 나머지 세 동작(오버라이드/삭제 후 재시작/ + 끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처 — 구현 + 단계에서 확정. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22e392c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/initreq diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fec7a36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +## 언어/모델 관례 (기존 메모, 유지) + +사용자는 한국 유저임을 유의해. 사용자가 보게 될 것은 한국어로 띄워주는 게 +좋아. 너가 보는 것들(코드 주석 등)은 원하는 언어여도 되는 것(가장 성능이 좋을 +영어를 쓰든 그래도 됨, 예를 들어 이 CLAUDE.md도 영어여도 무방하지만 지금은 +한국어로 유지). 사용자가 검토해야 하는 것(plan, backlog 등)은 한국어로 써. +코드 안 주석은 공식성을 유지하기 위해 굳이 한국어일 필요 없음, 영어 가능. + +또 토큰 맥싱에 유의해 — 아주 작은 테스크라 충분히 작은 모델이 쓸 수 있으면 +haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는 리서치는 메인 +컨텍스트를 부패시키니 Agent로 위임할 것(아래 "작업 방식" 참고). + +## 이 프로젝트가 뭔지 + +Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 +프로젝트. 목표는 개별 프로덕트가 아니라 **라이브러리**로서의 코드 퀄리티와 +지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은 +길게 잡음. + +**지금은 설계/계획 단계이고 구현은 아직 시작 전** — 저장소 루트에 실제 소스 +코드(`src/` 등)가 없음. 2026-08-03 여러 질의응답 라운드를 거쳐 핵심 아키텍처 +결정 대부분이 확정됨(Store 책임 분리, `process`/`retract` 디스패치 모델, +Signal 미채택, Ref 역할, Store 문법 인체공학, 트윈 기본 오버라이드, Slot +재마운트 에러 처리, 순수성→이식성 재정의) — `.claude/question.md`의 "확정됨" +절 참고. 이전에 시도했다 폐기한 v2 재작성 시도(`.claude/initreq/quad2-try`)도 +리서치 완료 — OOP 상속/커스텀 파서/Slot 스텁은 확인된 죽은 접근이라 반복 금지, +`Pipe`의 copy-on-write 절충안은 살려볼 후보. 남은 건 세부 함수 시그니처 +(`:Compute`가 의존값을 읽는 방법, `CreatedRef`/`Store.Combine`류 정확한 이름) +정도 — `.claude/question.md`의 "착수하면서 확인" 절 참고. + +## 계획 문서 구조 + +`.claude/README.md`가 색인. 요약: +- `.claude/base/` — 확정된 아키텍처/컨텍스트, plan/done 개념 없음. 먼저 + `.claude/base/architecture.md`를 읽을 것. +- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. +- `.claude/qa-request/`, `.claude/archive/`, `.claude/feedback/` — 구현 + 시작되면 쓰기 시작함, 지금은 비어있음. +- `.claude/initreq/` — 클론해둔 참고 레포(quad v1, Fusion, Vide, rbvm, tbox, + code-docker) + 원본 요청. **읽기 전용, `.gitignore`로 커밋 제외됨** — 내용을 + 다른 곳으로 옮기지 말고 항상 원본 그대로 둘 것. 리서치가 더 필요하면 이 + 폴더를 다시 파고들 것. +- `.claude/question.md` — 사용자가 답해야 할 질문 전체 취합(우선순위순). +- 루트 `HUMAN_TODO.md` — 사람만 할 수 있는 일(로컬 GUI 조작, 스케줄/루프 + 설정 등). + +## 작업 방식 + +- **소스코드를 많이 읽어야 하는 리서치는 Agent(Explore)로 위임** — 메인 + 컨텍스트 보호. 이미 완료된 v1/rbvm/tbox/Fusion/Vide 리서치 결과는 + `.claude/base/`에 정리되어 있으니 중복 조사하지 말고 먼저 그걸 볼 것. +- **병렬화 가능한 작업은 Agent 여러 개를 한 메시지에 동시 호출.** 서로 독립적인 + 파일/주제를 다루는 리서치나 구현 조사가 여기 해당. +- **크리티컬한 설계 결정은 구현으로 밀어붙이지 말고 plan을 research/에 남긴 채 + 연기.** 사용자는 Lua/Roblox 엔진을 깊이 아는 사람 — 근거와 선택지를 문서에 + 정리해두면 사용자가 깨어있을 때 훑어보고 답해줄 것. `.claude/question.md`에 + 반드시 반영. +- **작업이 끝나면(또는 방향이 바뀌면) 항상 자기 문서화** — 완료된 걸 다시 + 조사하게 되는 재작업을 막기 위함. `.claude/base/`로 승격, `.claude/qa-request/`로 + 이동, 또는 문서 자체를 갱신. code-docker/webmanager의 `.claude/` 관리 방식이 + 좋은 예시(`.claude/initreq/code-docker/webmanager/.claude/README.md` 참고). +- **Roblox Studio MCP 연결 시 주의**: Studio는 잘 죽는 편 — 죽었을 때 살리려고 + 위험한 명령을 반복 시도하지 말 것. 그런 상황이면 MCP 없이 할 수 있는 작업만 + 하거나 대기. 연결 방법은 `HUMAN_TODO.md` 1번 항목 참고(사용자가 Studio에서 + 베타 기능을 켜줘야 함). +- **`SAFETY.md` 반드시 지킬 것** — (1) GitHub 등 외부 git 호스팅에 이 레포를 + push하지 말 것, 모델의 git 작업 공간은 사용자가 별도로 마련해줄 제한 계정 + (예: git.qwreey.moe) 전용으로 국한됨 — 그 계정/원격이 설정되기 전까지는 + **로컬 git 커밋까지만** 하고 원격 추가/푸시는 하지 말 것. (2) Roblox Studio는 + 메인 계정이 아닌 별도 계정으로만 사용 — 로그인 계정 전환을 사용자가 안 해줬다면 + Studio 관련 작업(MCP 연결 등)을 진행하지 말고 대기. + +## 지금 할 일 (우선순위순) + +1. **[다음 세션 최우선] 확정된 설계 검증 라운드.** 2026-08-03에 여러 라운드에 + 걸쳐 확정한 아키텍처 결정들(`.claude/question.md`의 "확정됨" 절 전체 — + Store 책임 분리, `process`/`retract` 모델, Ref 역할, Store 문법, `:With`/ + `:Compute`, 트윈 기본값, Slot 에러 처리, 순수성→이식성 재정의 등)을 **사용자가 + 명시적으로 요청한 방식으로 재검증할 것**: 각 결정을 작게 쪼개서 예/아니오로 + 답할 수 있는 질문으로 만들어 `AskUserQuestion`으로 하나씩 확인. 목적은 이 + 설계 라운드에서 내가(에이전트가) 잘못 이해했거나 성급히 확정한 부분을 + 찾아내 프로젝트 전반의 기틀과 정확성을 높이는 것 — 이미 답변받은 걸 다시 + 묻는 게 아니라, "정말 이렇게 이해한 게 맞는지"를 세분화해서 다시 짚는 것. + `.claude/base/`, `.claude/research/` 각 문서를 훑으며 검증 질문 목록을 먼저 + 만들고, 한 번에 다 던지지 말고 문서/주제 단위로 나눠서 진행할 것. +2. 검증 라운드가 끝나면 `research/bind-system-plan.md`/ + `research/module-lifecycle-plan.md`를 `base/`로 승격하고, + `base/architecture.md`에 "구현 착수" 섹션을 추가해 실제 소스 트리 구조 + (어느 서브패키지가 뭘 갖는지)를 확정 — 이 시점부터 `qa-request/`/`archive/` + 폴더가 실제로 쓰이기 시작함. +3. 남은 세부 시그니처(dependency 값 읽는 방법, `CreatedRef`/`Store.Combine`류 + 정확한 이름)는 검증 라운드 중 자연스럽게 같이 확정 가능. +4. `research/purity-and-effects-plan.md`, `research/existing-instance-bind-plan.md`는 + 급하지 않음 — 스코프 논의만 필요, 구현 착수를 막지 않음. +5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 + (`HUMAN_TODO.md` 2번 항목). + +## 인수인계 메모 (2026-08-03 세션 종료 시점) + +이 세션에서 `.claude/` 전체 스캐폴드 + 대부분의 핵심 아키텍처 결정을 완료함. +사용자가 다음 세션 시작 시 이렇게 요청함: "각 디자인 부분을 작게작게 질문으로 +만들어서, 이게 맞나요? 예/아니오로 대답할 수 있는 걸 던져가며 검증해보자. +프로젝트의 전반적 기틀 잡힘과 정확성을 올리기를 할 것이라 해둬." — 위 "지금 +할 일" 1번이 이 요청을 그대로 반영한 것. 로컬 git 저장소는 이 세션에서 초기화 ++ 첫 커밋까지 해둠(원격 없음, `SAFETY.md` 참고 — 원격은 사용자가 제한 계정을 +마련해줘야 추가 가능). diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md new file mode 100644 index 0000000..aa4e051 --- /dev/null +++ b/HUMAN_TODO.md @@ -0,0 +1,65 @@ +# HUMAN_TODO — 사용자(사람)만 할 수 있는 일 + +에이전트가 못 하거나(로컬 GUI 조작, 외부 계정/기기 필요) 사용자의 결정이 필요해서 +멈춰둔 것만 여기 모음. 설계 질문(디폴트 이미 잡아두고 진행 중, 급하지 않음)은 +`.claude/question.md`에 따로 있음 — 그건 안 봐도 진행에 지장 없고, 이 문서는 실제로 +사람이 뭔가 눌러/타이핑해야 풀리는 것만 담음. + +## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용) + +Roblox가 2026-02부터 Studio에 **MCP 서버를 내장**했음 — 예전처럼 Rust로 직접 +`studio-rust-mcp-server`를 빌드할 필요 없이 Studio 자체 베타 기능으로 켜면 됨. + +**설정 방법** (사용자가 로컬에서 직접): +1. Roblox Studio → File → Studio Settings → Beta Features → **MCP Server** 활성화 +2. 기본적으로 `localhost:3004`에서 리슨 시작함 +3. Claude Code의 MCP 클라이언트 설정(`.mcp.json` 등)에 이 로컬 서버를 추가 — + 이 설정 파일 자체는 내가 대신 만들어줄 수 있으니, Studio에서 베타 기능만 켜고 + "여기 프로젝트에 연결해줘"라고 말해주면 이어서 진행함. +4. 노출되는 툴: `create_object`, `set_property`, `set_script_source`, + `execute_luau` 등 — Undo 히스토리를 존중해서 Ctrl+Z로 되돌릴 수 있음(안전망 있음). + +**주의(사용자가 이미 말한 것)**: Roblox Studio는 잘 죽는 편 — 죽었을 때 살리려고 +위험한 명령을 반복 시도하지 않을 것이고, 그런 날엔 MCP 없이 할 수 있는 작업만 +하거나 대기함. 이 안전 원칙은 `CLAUDE.md`에도 적어둠. + +**해야 할 일**: 테스트용 place 파일(빈 place 하나, 또는 `quad/test.project.json` +기반 rojo 싱크 대상)을 열어서 베타 기능만 켜주면 됨. 이후 MCP 서버 설정 파일 +작성/연결 확인은 내가 진행 가능. + +**`SAFETY.md` 제약**: Studio는 메인 계정이 아닌 별도 계정으로만 사용하기로 +되어 있음 — 계정 전환 여부를 알려주기 전까지는 MCP 연결을 진행하지 않고 대기함. + +## 0. (SAFETY.md) Git 원격 저장소 계정 마련 + +`SAFETY.md`에 따라 이 레포는 GitHub 등 외부 호스팅에 올리지 않기로 되어 있음 — +모델(나)의 git 작업 공간은 사용자가 마련해줄 제한 계정 전용이어야 함(예: +git.qwreey.moe에 제한된 계정 생성). 로컬 git 저장소는 이미 초기화 + 초기 +커밋까지 해뒀음(원격 없음) — 원격을 추가하고 싶으면 그 계정 정보를 알려줄 것, +그 전까지는 로컬 커밋만 계속 쌓아둠. + +## 2. 자율 작업 루프/스케줄 설정 + +사용자가 잠들어 있는 동안에도 계획된 TODO를 이어서 진행하길 원한다는 요청이 있었음 +(`req.md` 참고). 이건 세션을 넘어 지속되는 자동 실행이라 다음 중 하나를 사용자가 +직접 트리거해야 함(에이전트가 임의로 크론/무인 실행을 켜는 건 파급力이 커서 먼저 +확인받는 게 맞다고 판단해 보류함): + +- `/loop` — 지금 세션 안에서 일정 주기로 스스로 다음 작업을 이어가게 함(사용자 + 대화 종료 전까지). 간단한 자율 반복엔 이걸로 충분. +- `/schedule` — 진짜 cron 스케줄로 별도 클라우드 에이전트를 반복 실행(예: 매일 + 새벽에 큐에 있는 다음 plan 문서 하나씩 처리). 무인 상태로 더 오래/여러 날에 + 걸쳐 진행하고 싶다면 이쪽. + +원하는 주기/범위를 알려주면 그에 맞춰 설정해줄 수 있음. 어떤 걸 골라도, 진행한 +내용은 항상 `.claude/`에 자기 문서화(완료 표시, 다음 TODO 갱신)해서 다음 세션이나 +사람이 바로 이어받을 수 있게 할 것. + +## 3. `.claude/question.md` 검토 (급하지 않음) + +디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 +진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 +마음에 안 드는 것만 답해주면 됨 — 막고 있는 항목은 없음. + +--- +Sources (MCP 리서치): [Roblox/studio-rust-mcp-server](https://github.com/Roblox/studio-rust-mcp-server), [How to Connect Claude Code to Roblox Studio — Clauder Navi](https://www.clauder-navi.com/en/claude-roblox-studio) diff --git a/SAFETY.md b/SAFETY.md new file mode 100644 index 0000000..cb2a7a5 --- /dev/null +++ b/SAFETY.md @@ -0,0 +1,2 @@ +- Git 레포지토에리 대해 접근을 주지 마세요: 모델이 어디로 튈 지 모르기에, github 에는 작업하지 않음. 그래서 모델의 작업 공간은 오직 git.qwreey.moe(예를 들어) 에 제한적 계정으로 계정을 생성해 주는것으로 사용. +- Code-docker 활용: 컨테이너 환경으로 한번 격리하여 사용할 수 있도록 유도. studio 환경은 메인 계정이 아닌 다른 계정을 사용하여야함 From 0dbbc3d0b104616d01c1b37d93a376c4e9fd95ce Mon Sep 17 00:00:00 2001 From: qwreey Date: Tue, 4 Aug 2026 12:06:26 +0900 Subject: [PATCH 02/62] =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B4=EB=93=9C(2026-08-04)=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-03 확정 사항 전체를 AskUserQuestion으로 하나씩 재검증. 대부분 그대로 확인됐으나 State 프리미티브 존재 여부(있어야 함으로 정정), Pipe copy-on-write 후보(폐기, state(state) 조합으로 대체), Slot retract 시 동작(폐기로 확정) 등 실제 정정이 발생 — Store/State/Source 온톨로지가 다음 세션 최우선 열린 설계 스레드로 새로 부상. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 4 +- .claude/base/architecture.md | 14 ++- .claude/base/lifecycle-pattern.md | 33 +++++++ .claude/base/store-semantics.md | 59 ++++++++++--- .claude/question.md | 46 +++++++++- .claude/research/bind-system-plan.md | 101 +++++++++++++++++++--- .claude/research/module-lifecycle-plan.md | 10 +++ .claude/research/slot-plan.md | 14 ++- CLAUDE.md | 76 +++++++++------- 9 files changed, 294 insertions(+), 63 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index 00c54ed..3526028 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -28,13 +28,13 @@ | `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선 | | `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료 | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | -| `store-semantics.md` | Store는 부작용 허용이 기본, 별도 State 프리미티브는 안 만듦 | +| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — Store/State/Source 온톨로지는 `research/bind-system-plan.md`에서 진행 중 | ## `research/` — 아직 착수 전, 상의 필요 | 문서 | 내용 | 우선순위 | |---|---|---| -| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, quad2-try 리서치 결과. 핵심은 확정, 세부 시그니처만 남음 | 최상 — 다른 모든 설계가 이 위에서 조립됨 | +| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델(확정), Ref(확정), quad2-try 리서치 결과. **Store/State/Source 온톨로지는 2026-08-04에 새로 열린 미해결 설계 스레드**(다음 세션 최우선) | 최상 — 다른 모든 설계가 이 위에서 조립됨 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정됨 | 최상 — 확정, 구현 착수 시 API 세부만 조정 | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw | 상 — bind-system 확정 후 | | `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel | 중 — 세부 옵션만 남음 | diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 4a23973..a244563 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -54,9 +54,9 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 별개 라이브러리로 존재해야 함(v1 `lang.lua`의 전역 스코프 버그 등은 `base/quad-v1-architecture.md` 참고 — 애초에 반면교사). 11. **커스텀 Signal 클래스 미구현.** 콜백 정도로 이벤트 바인드 뒤에 함수를 - 넣는 것만으로 충분하다고 판단(단, `base/lifecycle-pattern.md`의 rbvm 리서치 - 결과 rbvm의 커스텀 Signal이 실제로는 재사용 가능해 보여서 상충 — 열린 질문으로 - `.claude/question.md`에 있음). + 넣는 것만으로 충분하다고 판단. (이전 초안엔 "rbvm의 Signal이 재사용 + 가능해 보여 상충한다"는 메모가 있었으나 2026-08-04 검증 라운드에서 최종 + 확정으로 재확인 — 더 이상 열린 질문 아님.) 12. **멀티 타겟(pluggable 백엔드) — 특히 GTK 지원까지 염두.** Roblox 전용 렌더 기술(react.lua, Fusion)은 결국 외부 개발자 유인이 없어 발전이 더딜 거라는 문제의식. 결과적으로 `plug/roblox`, `plug/base` 정도로 나뉠 전망 — base가 @@ -75,3 +75,11 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 바인드 시스템 디스패치, Slot 설계 세부, Tween 플러깅, 모듈 라이프사이클/누가 Store를 구현하는가, 순수함수 범위, 이미 생성된 인스턴스에 대한 바인드 — `.claude/research/` 각 문서 참고, 전체 색인은 `.claude/README.md`. + +**가장 시급한 미정 사항(2026-08-04부터, 다음 세션 최우선)**: Store/State/ +Source 온톨로지 — Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, +`store "key"`처럼 접근할 때마다 그 source를 감싸는 새 State(자기 고유 +value 없는 조합 가능한 캐시)를 반환한다는 모델까지는 나왔으나, `:Compute` +캐싱/무효화 전략, Luau 타입 시스템에서 커링 호출의 타입 추론 문제 등 세부는 +전부 열려있음. `research/bind-system-plan.md`의 "Store/State/Source +온톨로지" 절, `.claude/question.md`의 "최우선 새 열린 질문" 절 참고. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 7fb179a..b1765a8 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -123,6 +123,39 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 저장소), 다른 하나는 "언제까지 실행되어도 되는지"(생명 바인드 + canExecute)를 다룸. 둘 다 base가 제공하는 범용 유틸로 확정. +## 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"이라는 이름은 부적절하다는 사용자 피드백(완전 소멸 정리로 오인되기 diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 73e0568..2823c4d 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -1,6 +1,8 @@ -# Store 의미론 — 부작용 허용, State 프리미티브 없음 +# Store 의미론 — 부작용 허용, State는 Store 위의 조합 가능한 캐시 레이어 -**상태**: base — 확정된 설계 결정 두 가지. 원본: `.claude/initreq/raw-userinput.md` +**상태**: base — 부작용 허용/Store 문법 부분은 확정. State/Source 온톨로지는 +2026-08-04 검증 라운드에서 새로 열린 진행 중인 설계 스레드(`research/ +bind-system-plan.md` 참고). 원본: `.claude/initreq/raw-userinput.md` "store는 부작용을 허용함" / "state는 어떻게 구현하는가" 절. ## Store는 부작용을 허용하는 게 기본 디자인 @@ -14,16 +16,53 @@ "당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`research/ purity-and-effects-plan.md`와 연결됨). -## 별도 `State` 프리미티브는 만들지 않는다 (기본값) +**보강(2026-08-04 검증 라운드): 부작용은 심각도가 다른 두 갈래로 나뉜다.** -클래스 자신이 필요한 state가 있으면 그냥 클래스 안에서 `Store`를 만들면 됨 — -Store는 부분집합으로 쪼개 전달하는 것도 충분히 가능하다고 보기 때문에, 굳이 -"단일 값 저장용" State를 별도로 만들 필요성을 못 느낌. 나누고 싶으면 사용자가 -알아서 나누면 됨(사용자 자유). +1. **국소적 부작용** — 입력으로 받았거나 자신이 만들어 소유한 대상에 대한 + 부작용(예: 렌더 리턴 아래에서 옵저빙해서 자기 slot을 갱신). 이건 편의성이 + 커서 적극 환영하는 영역. +2. **경계를 넘는 부작용** — globalStore처럼 컴포넌트 바깥의 전역 상태를 + 다루는 경우. 게임 UI 특성상(스킬/주변 환경에 영향받는 UI 등) 완전히 + 막을 수는 없지만, 라이브러리로 재사용하려는 컴포넌트가 이런 부작용을 + 가지면 이식성이 떨어짐(`research/purity-and-effects-plan.md`와 연결). -**단서**: 구현하다가 실제로 State가 있는 게 더 편해지는 지점이 나오면 그때 -추가할 수 있음 — 지금은 "필요성이 확인 안 됐다"는 판단이지 "절대 안 만든다"는 -확정이 아님. 구현 라운드에서 이 판단이 바뀌면 이 문서를 갱신할 것. +**미해결 열린 질문**: state를 옵저빙해서 나온 결과로 slot에 `clear`/`add` 같은 +연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면 어떻게 되는가 — state가 +생성되는 지점과 slot이 적용되는 지점이 서로 다른 스코프라, "state 변경이 +발생했을 때 그 slot이 아직 살아있는지"를 어떻게 연관지어 확인할지 아직 명확한 +설계가 없음(`isInit=false`일 때는 허용, `isInit=true`이고 생존 확인 함수가 +거짓이면 불허 정도의 방향은 있으나 미완성). 사용자 본인도 "더 리서치가 필요" +하다고 명시적으로 표시 — `research/bind-system-plan.md`의 "Store/State/Source +온톨로지" 스레드와 함께 다룰 것. + +## 정정(2026-08-04 검증 라운드): `State` 프리미티브는 실제로 필요하다 + +**이전 버전의 이 절("State 프리미티브는 만들지 않는다")은 틀렸음 — 사용자가 +검증 라운드에서 직접 정정.** 정확한 모델: + +- **Store는 "source 집합체"이자 state를 만들어주는 존재.** 실제 값이 존재하고 + 변경될 수 있는 단일 지점은 source(v1의 "값의 근원"에 해당) — store는 이런 + source들의 모음. +- **State는 source(또는 다른 state)를 받아 캐싱만 하는 존재, 자기 고유의 + 독립적 value 개념이 없다.** 단일 값에 대한 state 생성은 store가 자동으로 + 해주지만, 그 결과를 다시 분기하고 싶으면(하나의 파생 스트림에서 여러 + 소비자가 각자 다른 추가 compute를 얹고 싶은 경우) `state(state)`처럼 기존 + state의 결과를 받아 새 state를 만드는 조합이 필요. +- **store에서 state를 얻는 연산(예: `store "key"`)은 항상 새 state 인스턴스를 + 반환한다** — state 자체가 캐시되어 재사용되는 게 아니라, source만 store에 + 귀속된 유일한 실체이고 그 위의 state는 매번 새로 생성됨. +- 이건 quad2-try(폐기된 이전 시도)의 `Pipe` copy-on-write 절충안을 대체하는 + 방향으로 좁혀짐 — 별도 `Pipe` 타입을 만들어 소유권/버전 가드를 넣는 대신 + State 자체가 "파이핑 결합체"이고 `state(state)`로 분기하면 될 걸로 보임 + (`Pipe` 후보는 사실상 폐기 쪽으로 기움). 상세는 `research/bind-system-plan.md`의 + "Store/State/Source 온톨로지" 절 참고 — **아직 완전히 결론난 설계는 아니고, + 구현 단계에서 더 다뤄야 할 진행 중인 스레드.** + +미해결로 남은 것: `:Compute`의 캐싱/무효화 전략(값이 바뀌었는데 듣는 소비자가 +없으면 연산을 미루는 dirty-flag 방식 등), Luau 타입 시스템에서 `store "key"` +같은 커링 호출이 오버로드 함수 타입으로 `state`를 정확히 추론하기 어려운 +문제(문자열 리터럴이 as-const로 좁혀지지 않는 문제) — 둘 다 열린 채로 +`research/bind-system-plan.md`에서 계속 다룰 것. ## Store 값 설정 문법 — v1 인체공학 유지 (확정) diff --git a/.claude/question.md b/.claude/question.md index 4dbbff3..25e52e5 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -5,6 +5,45 @@ 있음. 사용자가 Lua/Roblox 엔진에 대해 깊이 아는 사람이라는 전제로, 우선순위 높은 것부터 정렬. +## 2026-08-04 검증 라운드 완료 + +아래 "확정됨" 절 전체(architecture.md 14개 항목, lifecycle-pattern.md, +store-semantics.md, bind-system-plan.md, module-lifecycle-plan.md, +slot-plan.md, tween-plan.md)를 `AskUserQuestion`으로 하나씩 예/아니오 재검증 +완료 — 대부분 그대로 확인됐지만, 아래는 검증 과정에서 실제로 문서가 수정된 +항목: + +- **`State` 프리미티브는 "안 만든다"가 아니라 실제로 필요함** — 정정 완료, + `base/store-semantics.md` 참고. **이 결과로 Store/State/Source 온톨로지 + 전체가 새로운 열린 설계 스레드로 떠올랐음** — 아래 "최우선 새 열린 질문" + 참고. +- Slot의 `retract` 동작이 "부모 위임" 잠정안에서 "폐기(옮기지 않음)"로 확정 + — `research/slot-plan.md`. +- quad2-try의 `Pipe` copy-on-write 후보는 사실상 폐기, `state(state)` 조합 + 모델로 대체 — `research/bind-system-plan.md`. +- `Connected` 체크/GC 위임/`Destroying` 훅 관련 뉘앙스 보강(엔진별 인터페이스 + 주입, quad는 rbvm보다 즉시정리 필요성이 낮음) — `base/lifecycle-pattern.md`. +- base 유틸(per-instance 저장소, 생명 바인드)은 인터페이스만, 실제 구현은 + `RobloxFactory(BaseModule)`류 백엔드 팩토리가 주입 — `research/ + bind-system-plan.md`. + +## 최우선 새 열린 질문 (검증 라운드에서 새로 터져나옴) + +- **Store/State/Source 온톨로지 전체** — store는 source 집합체, state는 + source를 감싸는 조합 가능한 캐시(자기 고유 value 없음), `state(state)`로 + 분기. `:Compute`의 캐싱/무효화 전략(dirty-flag 등), `emit` 필요 여부, Luau + 타입 시스템에서 `store "key"` 커링 호출의 `state` 추론 문제까지 전부 + 미정 — 다음 세션 최우선 논의 대상. → `research/bind-system-plan.md`의 + "Store/State/Source 온톨로지" 절. +- **부작용이 slot 생존 여부와 어떻게 연관되는가** — state 옵저빙 결과로 + slot을 조작할 때, 그 시점에 대상 slot이 죽어있으면 어떻게 처리할지 사용자도 + 아직 명확한 답이 없다고 명시. → `base/store-semantics.md`. +- **인스턴스 생성/이벤트 네이밍 인체공학** — `Quad "Frame"` 문자열 방식 vs + `DI.Frame` 필드 접근 방식(자동완성/타입추론 트레이드오프). → `research/ + bind-system-plan.md`. +- `RobloxFactory` 같은 백엔드 팩토리를 같은 base에 중복 호출했을 때의 가드 + 동작, 모듈 스코핑(`New()`)과의 관계. → `research/bind-system-plan.md`. + ## 확정됨 (2026-08-03 질의응답 라운드, 더 이상 열려있지 않음) - **Store 책임 분리**: base가 `LifetimeHandle` 추상화 + store-bind의 재실행 @@ -50,8 +89,11 @@ - **OOP 상속/`--&` 커스텀 파서/Slot 스텁은 확인대로 죽은 접근** — 절대 반복 금지, Slot은 from-scratch 설계 그대로 진행(재조사 불필요). - **mutate-vs-`fromState` 긴장 관계**: quad2-try의 `Pipe` copy-on-write - 절충안(유일한 tip일 때만 뮤테이션, 아니면 복사)이 유력 후보로 좁혀짐 — 단 - 소유권/버전 가드를 제대로 설계해야 함(원본은 가드 없이 방치돼 있었음). + 절충안(유일한 tip일 때만 뮤테이션, 아니면 복사)이 한때 유력 후보였으나 + **2026-08-04 검증 라운드에서 사실상 폐기로 재평가됨** — 별도 `Pipe` 타입 + 대신 State 자체가 파이핑 결합체이고 `state(state)`로 분기하는 쪽이 더 + 간단하다는 판단(위 "최우선 새 열린 질문"의 Store/State/Source 온톨로지 + 절로 흡수됨). - **`Depend(...)` 액션, `:With` 네이밍**은 이전 시도에서도 지향했던 것과 일치 — 그대로 채택. → `research/bind-system-plan.md` diff --git a/.claude/research/bind-system-plan.md b/.claude/research/bind-system-plan.md index bbf67ea..d6744cf 100644 --- a/.claude/research/bind-system-plan.md +++ b/.claude/research/bind-system-plan.md @@ -52,6 +52,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 그 인스턴스에 적용하도록 돕는 역할에 가깝다. 그래서 핸들러가 "나중에 생길 대상"을 비동기로 기다릴 필요 자체가 없음(아래 Ref 절 참고 — Ref는 다른 이유로 존재). + - **보강(2026-08-04)**: `inst`가 항상 살아있는 엔진 객체(Roblox Instance)일 + 필요는 없음 — 특정 백엔드에서 실제 엔진 객체 생성/바인딩 비용이 비싸면 + (예: 웹 DOM) 중간 표현으로 평범한 테이블을 만들고 나중에 그 테이블을 + 렌더링하는 것도 가능. 이건 core(base)가 신경 쓸 일이 아니라 각 최종 + 엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 — + base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 + 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량. - `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, `isHandlable(k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당. - 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 @@ -181,6 +188,52 @@ read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최 같은 축의 문제 — 옵션 2가 그 원칙과 더 잘 맞아 보이지만, 실현 가능성 자체가 아직 검증 안 됨. +## Store/State/Source 온톨로지 — 진행 중인 설계 스레드 (2026-08-04 검증 라운드에서 새로 열림) + +**이 절은 아직 결론난 설계가 아니다** — 검증 라운드 중 사용자가 실시간으로 +설계를 전개하며 나온 내용을 그대로 기록. 다음 세션에서 이어서 다룰 것. +`base/store-semantics.md`의 "State 프리미티브는 실제로 필요하다" 정정과 +직결됨. + +**핵심 온톨로지**: +- **Source** — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). +- **Store** — source들의 집합체. `store.a`/`store "a"`처럼 키로 접근하면 + 그 source를 감싼 **새 State**를 매번 만들어 반환(state가 store에 캐시되어 + 재사용되는 게 아님 — source만 store에 귀속된 유일한 실체). +- **State** — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 + 독립적 value 개념이 없음. `state(state)`로 기존 state의 결과를 받아 새 + state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 + 합성 가능"이라는 원래 목표를 구현하는 방식. +- `:With(...)`/`:Compute(fn)`은 실제로는 state 위의 연산 — + `store "key1":With(store "key2"):Compute(function(key1) return key1 + + store.key2.value end)`처럼, with한 값을 fn이 클로저로 직접 읽는 모양. + +**미해결 세부 사항**: +- **`:Compute` 캐싱/무효화 전략** — 매번 새로 계산할지, 캐싱해두고 무효화 + 플래그로 관리할지 미정. 후보: 값이 바뀌었는데 듣는 소비자가 없으면 연산은 + 미루고 `invalid=true`만 세워두고, 필요해질 때(듣는 사람이 생기거나 값을 + 읽을 때) 실제로 연산하고 `true`→캐싱 후 `false`로 되돌리는 dirty-flag 방식. + "store가 state를 **만드느냐** 아니면 **저장하느냐**"의 문제와 직결 — + 사용자 판단은 "만든다" 쪽(저장한다고 하면 한 곳에서 `:Compute`를 붙이면 + 다른 소비자도 전부 그 compute된 값을 읽게 되어버리는 오염 문제 발생). +- **`emit` 필요 여부** — store가 값 변경 시 관련된 모든 state에 emit해야 + 하는 구조가 맞는지 확신은 없지만, 그 외의 방법이 안 보인다는 게 사용자 + 현재 판단. `state(from) / state() -> (state, setState)` 같은 팩토리 + 모양도 후보로 언급됨(React의 `useState`류 페어 반환과 유사). +- **Luau 타입 시스템 제약** — `store "key"` 같은 커링 호출로 `state`의 + `T`를 정확히 추론하려면 오버로드 함수 타입(`(("a") -> number) | (("b") + -> boolean)`)이 필요한데, Luau는 문자열 리터럴 인자를 자동으로 `as const` + 취급하지 않아서 타입이 좁혀지지 않는 문제가 있음. `store.states.a`처럼 + 필드 접근으로 우회하거나, `store.a`가 바로 state를 반환하고 + `state.value = x`로 설정 가능하게 하는 대안도 검토됐으나, 후자는 "다른 + source로부터 파생된 state에 value를 직접 설정하면 안 된다"는 문제와 + 충돌(store가 실제 값을 담는 유일한 주체여야 함). `state`(compute + 결과)가 제대로 바인딩 안 됐을 때 생기는 타입 문제는 일단 UB로 두기로 함. +- **`Pipe`(quad2-try 후보)는 사실상 폐기 쪽으로 기움** — 별도 `Pipe` 타입에 + 소유권/버전 가드를 넣어 재설계하는 대신, State 자체를 파이핑 결합체로 + 보고 `state(state)`로 분기하는 쪽이 엔지니어링상 더 쉬워 보인다는 게 + 사용자의 최신 판단(2026-08-03 라운드의 "Pipe COW가 유력 후보"보다 우선함). + ## quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지 `.claude/initreq/quad2-try/out/quad-core`에 정확히 이 문제(Unix 파이프 영감의 @@ -235,9 +288,11 @@ State/스트림)를 다뤘던 이전 시도가 있었음. 조사 결과 요약: 이번 라운드에서 다시 요청한 것과 정확히 일치** — 우연이 아니라 원래 지향점이었던 것으로 보임, `:With` 이름 채택에 힘을 실어줌. -**종합**: 이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — 다만 Pipe의 -copy-on-write 절충안과 `Depend`/`:With` 네이밍은 quad-v2 설계에 그대로 -살려볼 가치가 있는 아이디어로 남김. +**종합**: 이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — `Depend`/`:With` +네이밍은 quad-v2 설계에 그대로 살려볼 가치가 있는 아이디어로 남지만, **Pipe의 +copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기 쪽으로 재평가됨** +(위 "Store/State/Source 온톨로지" 절 참고 — State 자체가 파이핑 결합체이고 +`state(state)`로 분기하는 쪽이 더 간단하다는 사용자의 최신 판단). ## 확정된 것 (더 이상 열린 질문 아님) @@ -249,15 +304,39 @@ copy-on-write 절충안과 `Depend`/`:With` 네이밍은 quad-v2 설계에 그 - **Ref**: 도입 확정(위 절 참고), 용도는 "id 기반 조회 대체"가 아니라 "외부 관리 instance를 점진적으로 다루기 위한 직접 참조 획득". +## base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입 (2026-08-04 보강) + +`base/lifecycle-pattern.md`가 말하는 "범용 유틸"(per-instance 상태 저장소, +생명 바인드 유틸)은 base가 직접 구현하는 게 아니라 **인터페이스만 정의** — +`inst`는 base 입장에선 `any`일 수 있음(다른 엔진일 수도 있으므로). 실제 +구현은 `RobloxFactory(BaseModule)` 같은 팩토리 함수가 `BaseModule`을 +뮤테이션해서 그 안에 실 구현체(`canExecute` 등)를 채워넣는 방식 — 사용자는 +`quad-base`/`quad-roblox`를 각각 import해서 `const quad = +RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 `quad` +패키지로 재수출할 필요 없음, 필요하면 만들어도 됨). + +**열린 질문**: `RobloxFactory`를 같은 `BaseModule`에 여러 번 호출하면 어떻게 +되어야 하는가 — 이미 초기화됐으면 무시(rbvm의 `InitNamespace`류 가드와 +유사하되, "라이브러리마다 수동 init" 패턴과는 다름)하는 쪽으로 기울어짐. +서로 다른 두 곳에서 같은 `base`를 require해서 `RobloxFactory`와 +`AnotherFactory`(가상의 예)를 각각 실행하는 경우처럼 충돌 가능성이 있는 +시나리오가 향후 모듈 스코핑(`New()`, `base/architecture.md` 13번) 논의를 +다시 촉발할 수 있음 — 지금은 열어만 둠. + +## 인스턴스 생성 / 이벤트 네이밍 인체공학 (2026-08-04 검증 라운드에서 새로 나온 열린 질문) + +`Quad "Frame"`처럼 문자열로 인스턴스 종류를 지정하는 방식은 타입 추론이 +어려움(위 온톨로지 절의 Luau 오버로드 문제와 같은 원인). PA님 DI 스타일은 +`DI.Frame`/`DI.TextLabel`처럼 필드 접근으로 만들어서 자동완성이 자연스럽게 +됨 — 목록에 없는 타입은 `DI.New<> "Frame"`류로 폴백. 이벤트도 +`Event ""` 대신 `On...`류 이름으로 필드 접근하면 타입/자동완성이 쉬워질 수 +있음. 아직 방향 결정 안 됨 — 다음 세션에서 다룰 것. + ## 남은 열린 질문 (`.claude/question.md`에도 취합) -- **`:Compute`가 `with`한 값을 정확히 어떻게 읽는가** — 클로저로 원본 store/ - register를 직접 캡처하는 것인지, `:Compute`가 특수한 접근자를 몸체 함수에 - 넘겨주는 것인지 구체 시그니처 미정. -- **mutate-in-place vs `fromState` 긴장 관계** — quad2-try의 copy-on-write - 절충안(위 절)이 유력한 후보로 좁혀짐. 실제 구현 시 "내가 유일한 tip인가" - 판단에 제대로 된 소유권/버전 가드를 설계하는 게 핵심 과제 — 원본처럼 - 가드 없이 가면 안 됨. +- **`:Compute`가 `with`한 값을 정확히 어떻게 읽는가** — 클로저로 직접 캡처하는 + 방향은 확정(위 온톨로지 절), 캐싱/무효화 전략(dirty-flag 등)과 `emit` 필요 + 여부는 아직 미정. - **`CreatedRef`(가칭)의 정확한 함수/옵션 이름** — children 배열에 아이템으로 넣는다는 방향과 생성/마운트 두 시점 모두 지원한다는 것은 확정, 정확한 API 이름만 남음. @@ -269,3 +348,5 @@ copy-on-write 절충안과 `Depend`/`:With` 네이밍은 quad-v2 설계에 그 라이프타임도 감싸게 될 텐데, 이중 해제(double-dispose) 방지가 필요한지 확인. 단, `base/lifecycle-pattern.md`의 "destroy 시점엔 아무것도 안 함" 원칙상 이중 해제 자체가 걱정할 필요 없는 개념일 수도 있음 — 재검토 필요. +- `RobloxFactory` 중복 호출/충돌 시나리오, 인스턴스 생성·이벤트 네이밍 + 인체공학 — 위 두 절 참고, 둘 다 새로 열린 질문. diff --git a/.claude/research/module-lifecycle-plan.md b/.claude/research/module-lifecycle-plan.md index 102cad6..aa25af0 100644 --- a/.claude/research/module-lifecycle-plan.md +++ b/.claude/research/module-lifecycle-plan.md @@ -84,3 +84,13 @@ bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 노출할지 정도(설계 방향 자체는 더 이상 열려있지 않음). - 넘버 바인드/프로바이더 인터페이스의 정확한 함수 시그니처(base가 요구하는 provider 인터페이스 계약)는 아직 미정 — 구현 착수 시 함께 확정. +- **네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 + 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함 — + 실제로는 `isHandlable`로 받을지 말지 결정하고 우선순위대로 스캔되는 + pluggable 참가자라는 점은 확정, 이름만 미정. +- base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 + 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 + 주입한다는 패턴이 확정됨 — 상세는 `research/bind-system-plan.md`의 "base + 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. 이 패턴을 + 중복 호출했을 때의 가드 동작(멱등 처리)과 모듈 스코핑(`New()`)의 관계는 + 여전히 열려있음. diff --git a/.claude/research/slot-plan.md b/.claude/research/slot-plan.md index 0f630c5..d8cd338 100644 --- a/.claude/research/slot-plan.md +++ b/.claude/research/slot-plan.md @@ -66,6 +66,13 @@ Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도 동일하게 적용. +**확정(2026-08-04 검증 라운드): retract되는 slot은 옮겨지지 않고 그냥 폐기된다.** +Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데이터형 — 새 slot +상태로 교체될 때 이전 slot의 내용을 다른 곳으로 옮기는 경로는 없음, 그냥 +버림. React의 portal(`<>`)류로 나중에 옮길 수 있게 하는 것도 검토됐으나 +**이번 마일스톤에서는 오버엔지니어링으로 판단, 하지 않음** — 필요성이 명확해지면 +그때 별도로 다시 논의. + ## 자식으로 넘기는 클래스 스토어 자식에게 내려주는 클래스 스토어는 부모 쪽에서 미리 만들어서 내려보내는 게 @@ -75,7 +82,6 @@ Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle- ## 열린 질문 (`.claude/question.md`에도 취합) -- 재마운트 에러 처리는 확정(throw). 남은 건 Slot 안 요소가 `retract`될 때 - "부모가 정리 후 재`process`"가 정말 항상 올바른 기본 동작인지, 아니면 slot - 자체가 일부 자기 정리를 해야 하는 케이스가 있는지 — 구현하면서 실제 사례로 - 재검증 필요. +- 재마운트 에러 처리(throw), retract 시 폐기(옮기지 않음) 둘 다 확정. 남은 건 + 실제 구현 단계에서 이 "폐기" 동작이 실사용에서 불편하지 않은지 재검증하는 + 정도 — 설계 방향 자체는 더 이상 열려있지 않음. diff --git a/CLAUDE.md b/CLAUDE.md index fec7a36..19cd65a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,15 +20,21 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 길게 잡음. **지금은 설계/계획 단계이고 구현은 아직 시작 전** — 저장소 루트에 실제 소스 -코드(`src/` 등)가 없음. 2026-08-03 여러 질의응답 라운드를 거쳐 핵심 아키텍처 -결정 대부분이 확정됨(Store 책임 분리, `process`/`retract` 디스패치 모델, -Signal 미채택, Ref 역할, Store 문법 인체공학, 트윈 기본 오버라이드, Slot -재마운트 에러 처리, 순수성→이식성 재정의) — `.claude/question.md`의 "확정됨" -절 참고. 이전에 시도했다 폐기한 v2 재작성 시도(`.claude/initreq/quad2-try`)도 -리서치 완료 — OOP 상속/커스텀 파서/Slot 스텁은 확인된 죽은 접근이라 반복 금지, -`Pipe`의 copy-on-write 절충안은 살려볼 후보. 남은 건 세부 함수 시그니처 -(`:Compute`가 의존값을 읽는 방법, `CreatedRef`/`Store.Combine`류 정확한 이름) -정도 — `.claude/question.md`의 "착수하면서 확인" 절 참고. +코드(`src/` 등)가 없음. 2026-08-03에 확정됐던 핵심 아키텍처 결정들(Store +책임 분리, `process`/`retract` 디스패치 모델, Signal 미채택, Ref 역할, Store +문법 인체공학, 트윈 기본 오버라이드, Slot 재마운트 에러 처리, 순수성→이식성 +재정의 등)은 2026-08-04에 `AskUserQuestion`으로 하나씩 재검증까지 마쳐서 +확정 상태 — `.claude/question.md`의 "확정됨" 절 참고. 이전에 시도했다 폐기한 +v2 재작성 시도(`.claude/initreq/quad2-try`)도 리서치 완료 — OOP 상속/커스텀 +파서/Slot 스텁은 확인된 죽은 접근이라 반복 금지, `Pipe`의 copy-on-write +절충안은 한때 살려볼 후보였으나 **2026-08-04에 사실상 폐기로 재평가**됨(State +자체가 `state(state)`로 분기하는 쪽으로 대체). + +**지금 유일하게 결론 안 난 핵심 설계 이슈는 Store/State/Source 온톨로지** +— 검증 라운드 중 "State 프리미티브는 안 만든다"던 기존 결정이 틀렸다는 게 +드러나며 새로 열림. `.claude/research/bind-system-plan.md`의 "Store/State/ +Source 온톨로지" 절, `.claude/question.md`의 "최우선 새 열린 질문" 절 참고 — +아래 "지금 할 일" 1번이 다음 세션이 여기서부터 시작해야 함을 명시. ## 계획 문서 구조 @@ -74,35 +80,41 @@ Signal 미채택, Ref 역할, Store 문법 인체공학, 트윈 기본 오버라 ## 지금 할 일 (우선순위순) -1. **[다음 세션 최우선] 확정된 설계 검증 라운드.** 2026-08-03에 여러 라운드에 - 걸쳐 확정한 아키텍처 결정들(`.claude/question.md`의 "확정됨" 절 전체 — - Store 책임 분리, `process`/`retract` 모델, Ref 역할, Store 문법, `:With`/ - `:Compute`, 트윈 기본값, Slot 에러 처리, 순수성→이식성 재정의 등)을 **사용자가 - 명시적으로 요청한 방식으로 재검증할 것**: 각 결정을 작게 쪼개서 예/아니오로 - 답할 수 있는 질문으로 만들어 `AskUserQuestion`으로 하나씩 확인. 목적은 이 - 설계 라운드에서 내가(에이전트가) 잘못 이해했거나 성급히 확정한 부분을 - 찾아내 프로젝트 전반의 기틀과 정확성을 높이는 것 — 이미 답변받은 걸 다시 - 묻는 게 아니라, "정말 이렇게 이해한 게 맞는지"를 세분화해서 다시 짚는 것. - `.claude/base/`, `.claude/research/` 각 문서를 훑으며 검증 질문 목록을 먼저 - 만들고, 한 번에 다 던지지 말고 문서/주제 단위로 나눠서 진행할 것. -2. 검증 라운드가 끝나면 `research/bind-system-plan.md`/ +1. **[다음 세션 최우선] Store/State/Source 온톨로지 설계.** 2026-08-04 검증 + 라운드 중 "State 프리미티브는 안 만든다"는 이전 결정이 틀렸다는 게 + 밝혀지면서 새로 터져나온 핵심 설계 이슈 — Store=source 집합체, State= + source를 감싸는 조합 가능한 캐시(`state(state)`로 분기), `:Compute` + 캐싱/무효화 전략, Luau 타입 시스템에서 커링 호출의 `state` 추론 문제 + 등이 전부 미정. `.claude/research/bind-system-plan.md`의 "Store/State/ + Source 온톨로지" 절에 지금까지 나온 내용이 정리되어 있음 — 이어서 설계를 + 구체화할 것. `.claude/question.md`의 "최우선 새 열린 질문" 절도 함께 참고. +2. 위 온톨로지가 어느 정도 정리되면 `research/bind-system-plan.md`/ `research/module-lifecycle-plan.md`를 `base/`로 승격하고, `base/architecture.md`에 "구현 착수" 섹션을 추가해 실제 소스 트리 구조 (어느 서브패키지가 뭘 갖는지)를 확정 — 이 시점부터 `qa-request/`/`archive/` 폴더가 실제로 쓰이기 시작함. -3. 남은 세부 시그니처(dependency 값 읽는 방법, `CreatedRef`/`Store.Combine`류 - 정확한 이름)는 검증 라운드 중 자연스럽게 같이 확정 가능. -4. `research/purity-and-effects-plan.md`, `research/existing-instance-bind-plan.md`는 +3. 남은 세부 시그니처(`CreatedRef` 정확한 이름, 인스턴스 생성/이벤트 네이밍 + 인체공학, `RobloxFactory`류 팩토리 중복 호출 가드)는 온톨로지 설계와 + 자연스럽게 같이 확정 가능. +4. `research/purity-and-effects-plan.md`(특히 "state 옵저빙 결과로 slot을 + 조작할 때 생존 여부 확인" 열린 질문), `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 필요, 구현 착수를 막지 않음. 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). -## 인수인계 메모 (2026-08-03 세션 종료 시점) +## 인수인계 메모 (2026-08-04 세션 종료 시점) -이 세션에서 `.claude/` 전체 스캐폴드 + 대부분의 핵심 아키텍처 결정을 완료함. -사용자가 다음 세션 시작 시 이렇게 요청함: "각 디자인 부분을 작게작게 질문으로 -만들어서, 이게 맞나요? 예/아니오로 대답할 수 있는 걸 던져가며 검증해보자. -프로젝트의 전반적 기틀 잡힘과 정확성을 올리기를 할 것이라 해둬." — 위 "지금 -할 일" 1번이 이 요청을 그대로 반영한 것. 로컬 git 저장소는 이 세션에서 초기화 -+ 첫 커밋까지 해둠(원격 없음, `SAFETY.md` 참고 — 원격은 사용자가 제한 계정을 -마련해줘야 추가 가능). +2026-08-03에 확정됐다고 표시된 결정 전체(architecture.md 14개 + lifecycle- +pattern/store-semantics/bind-system-plan/module-lifecycle-plan/slot-plan/ +tween-plan)를 `AskUserQuestion`으로 하나씩 예/아니오 검증 완료 — 상세는 +`.claude/question.md`의 "2026-08-04 검증 라운드 완료" 절. 대부분 그대로 +확인됐지만, 검증 과정에서 사용자가 실시간으로 설계를 더 전개하면서 **"State +프리미티브는 안 만든다"는 기존 결정이 틀렸다는 게 밝혀짐** — Store/State/ +Source 온톨로지 전체가 이번 세션에서 새로 열린 가장 중요한 설계 스레드로 +떠올랐고, 아직 결론이 안 났음(위 "지금 할 일" 1번). 그 외 자잘한 정정들(Slot +retract=폐기 확정, Pipe COW 후보 폐기 등)은 각 문서에 바로 반영해둠 — 재조사 +불필요. + +이전 세션(2026-08-03) 종료 시점 메모: `.claude/` 전체 스캐폴드 + 대부분의 +핵심 아키텍처 결정을 완료, 로컬 git 저장소 초기화+첫 커밋(원격 없음, +`SAFETY.md` 참고 — 원격은 사용자가 제한 계정을 마련해줘야 추가 가능). From c00e2e67d6e6985d3db398ade27fb69c7b3e6484 Mon Sep 17 00:00:00 2001 From: qwreey Date: Tue, 4 Aug 2026 13:57:01 +0900 Subject: [PATCH 03/62] =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=20=EB=9D=BC=EC=9A=B4=EB=93=9C(2026-08-04,?= =?UTF-8?q?=205=EC=B0=A8)=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bind-system-plan/module-lifecycle-plan/slot-plan을 research/에서 base/로 승격, quad-base(인터페이스)/quad-roblox(구현) 패키지 경계와 모노레포 소스 트리를 architecture.md에 확정. Slot의 base/roblox 분리, InstanceChild 핸들러 필요성도 함께 반영. --- .claude/README.md | 10 +- .claude/base/architecture.md | 96 +++++- .../{research => base}/bind-system-plan.md | 283 +++++++++++++----- .claude/base/comparison-fusion-vide.md | 4 +- .claude/base/lifecycle-pattern.md | 24 +- .../module-lifecycle-plan.md | 26 +- .claude/base/quad-v1-architecture.md | 4 +- .claude/{research => base}/slot-plan.md | 33 +- .claude/base/store-semantics.md | 44 +-- .claude/question.md | 130 ++++++-- .claude/research/tween-plan.md | 6 +- CLAUDE.md | 116 +++++-- 12 files changed, 582 insertions(+), 194 deletions(-) rename .claude/{research => base}/bind-system-plan.md (55%) rename .claude/{research => base}/module-lifecycle-plan.md (85%) rename .claude/{research => base}/slot-plan.md (73%) diff --git a/.claude/README.md b/.claude/README.md index 3526028..87bc2d9 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -14,7 +14,7 @@ | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 — 지금은 비어있음 | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | -| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `research/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | +| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | `research/`의 문서가 설계 확정되면 `base/`로 승격(또는 구현 착수 시 `qa-request/`행). 지금은 구현 라운드 전(설계 단계)이라 전부 `base/`/`research/`에만 @@ -28,15 +28,15 @@ | `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선 | | `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료 | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | -| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — Store/State/Source 온톨로지는 `research/bind-system-plan.md`에서 진행 중 | +| `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 패키지 경계까지 확정 | — | ## `research/` — 아직 착수 전, 상의 필요 | 문서 | 내용 | 우선순위 | |---|---|---| -| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델(확정), Ref(확정), quad2-try 리서치 결과. **Store/State/Source 온톨로지는 2026-08-04에 새로 열린 미해결 설계 스레드**(다음 세션 최우선) | 최상 — 다른 모든 설계가 이 위에서 조립됨 | -| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정됨 | 최상 — 확정, 구현 착수 시 API 세부만 조정 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw | 상 — bind-system 확정 후 | | `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel | 중 — 세부 옵션만 남음 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | 하 — 문서화 성격, 급하지 않음 | | `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index a244563..56f2eb4 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -35,7 +35,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 개념을 추가하면 라이브러리 복잡도가 너무 올라간다고 판단 — 당장은 TagService 그대로 사용. **대신 Ref가 도입됨** — 단 Ref의 용도는 "id로 조회"가 아니라 "외부에서 이미 관리되고 있는 instance를 quad로 점진적으로 마이그레이션/ - 래핑하기 위해 직접 참조를 얻는 것"(`research/bind-system-plan.md`의 Ref 절 + 래핑하기 위해 직접 참조를 얻는 것"(`base/bind-system-plan.md`의 Ref 절 참고) — 둘을 혼동하지 말 것. 6. **함수지향 디폴트, `:` 체이닝은 예외적으로만.** 스토어 바인드처럼 체인이 정말 편한 경우만 `:` 사용, 나머지는 외부 함수가 인스턴스를 인자로 받는 모양. @@ -44,7 +44,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo store 바인드를 받을 수도 있음. 8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""` 같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로 - 구현(`research/bind-system-plan.md`). + 구현(`base/bind-system-plan.md`). 9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher, 실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua`에 연결 안 된 죽은 코드였음, `base/quad-v1-architecture.md` 참고)은 렌더 @@ -70,16 +70,88 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo `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 # 값의 근원, 단일 지점 +│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기 +│ ├── Store.luau # source 집합체, dot-access, __newindex +│ ├── Dispatch/ +│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔 +│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) +│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) +│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) +│ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) +│ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" +│ ├── Ref.luau # CreatedRef 메커니즘(숫자 슬롯 참가자) +│ └── init.luau +└── quad-roblox/ + ├── wally.toml + └── src/ + ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) + ├── LifetimeHandle.luau # 실제 구현(Instance 생존 확인) + ├── PerInstanceState.luau # 실제 구현(weak-keyed table, Instance 키) + ├── Handlers/ + │ ├── Property.luau + │ ├── Event.luau # ReflectionService 기반 자동 판별 + │ ├── Attribute.luau + │ ├── Tag.luau # CollectionService + │ ├── Tween.luau # 높은 우선순위 store-bind 핸들러 + │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) + │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) + ├── DI/ + │ └── init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances) + └── init.luau +``` + +**남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된 +`slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서. +Tween/purity/existing-instance-bind는 여전히 `research/`에 남아있고 이 +구조 확정을 막지 않음. + ## 아직 미정 (research/로 분리됨) -바인드 시스템 디스패치, Slot 설계 세부, Tween 플러깅, 모듈 라이프사이클/누가 -Store를 구현하는가, 순수함수 범위, 이미 생성된 인스턴스에 대한 바인드 — -`.claude/research/` 각 문서 참고, 전체 색인은 `.claude/README.md`. +Tween 플러깅, 순수함수 범위, 이미 생성된 인스턴스에 대한 바인드 — +`.claude/research/` 각 문서 참고, 전체 색인은 `.claude/README.md`. 바인드 +디스패치/Slot/모듈 라이프사이클은 위 "구현 착수" 섹션대로 확정되어 +`.claude/base/`로 승격됨(`bind-system-plan.md`/`module-lifecycle-plan.md`/ +`slot-plan.md`). -**가장 시급한 미정 사항(2026-08-04부터, 다음 세션 최우선)**: Store/State/ -Source 온톨로지 — Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, -`store "key"`처럼 접근할 때마다 그 source를 감싸는 새 State(자기 고유 -value 없는 조합 가능한 캐시)를 반환한다는 모델까지는 나왔으나, `:Compute` -캐싱/무효화 전략, Luau 타입 시스템에서 커링 호출의 타입 추론 문제 등 세부는 -전부 열려있음. `research/bind-system-plan.md`의 "Store/State/Source -온톨로지" 절, `.claude/question.md`의 "최우선 새 열린 질문" 절 참고. +**Store/State/Source 온톨로지(2026-08-04 두 라운드에 걸쳐 확정)**: Store는 +source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 접근할 때마다 +그 source를 감싸는 새 State(자기 고유 value 없는 조합 가능한 캐시)를 +반환한다. 전파는 push-invalidate(신호만)/pull-recompute(`Get()` 시점) — +Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림. State는 +쓰기 대상이 아니고(값 쓰기는 항상 Store의 `__newindex`), 값 하나만 다룰 땐 +Store와 별개인 가벼운 `Source` 프리미티브를 씀. 남은 건 정확한 API 이름과 +"`store.key` dot-access를 타입 추론 1급 경로로 삼는다"는 제안의 정식 확인 +뿐 — `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절, +`.claude/question.md` 참고. diff --git a/.claude/research/bind-system-plan.md b/.claude/base/bind-system-plan.md similarity index 55% rename from .claude/research/bind-system-plan.md rename to .claude/base/bind-system-plan.md index d6744cf..0235c8b 100644 --- a/.claude/research/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -1,9 +1,12 @@ -# Bind 시스템 — pluggable key/value 핸들러 (핵심 모델 확정, 세부 사항만 남음) +# Bind 시스템 — pluggable key/value 핸들러 (base로 승격됨) -**상태**: research — 핵심 디스패치 모델(`process`/`retract`, 핸들러 4종 계약, -Signal 미채택, Ref 역할)은 사용자 확인 완료로 사실상 확정. 남은 건 세부 -시그니처(dependency array API, `CreatedRef` 모양) 뿐 — 이것들이 정리되면 -`base/`로 승격 예정. 원본: `.claude/initreq/raw-userinput.md` +**상태**: base — 핵심 디스패치 모델(`process`/`retract`, 핸들러 4종 계약, +Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 엔진은 +`quad-base`가 인터페이스로 소유, `quad-roblox`는 실제 구현만)까지 전부 +2026-08-04 세션에서 확정되어 `research/`에서 승격됨(`base/architecture.md`의 +"구현 착수: 소스 트리 구조 확정" 절 참고). 남은 건 세부 시그니처(dependency +array API, `CreatedRef` 모양) 뿐 — 구현 단계에서 자연히 정리됨. 원본: +`.claude/initreq/raw-userinput.md` "key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `base/quad-v1-architecture.md` ("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 `.claude/initreq/tbox` @@ -111,7 +114,7 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 -자연스럽게 맞음 — `research/slot-plan.md` 참고. +자연스럽게 맞음 — `base/slot-plan.md` 참고. ## Store가 Store를 저장 가능한가 @@ -188,51 +191,138 @@ read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최 같은 축의 문제 — 옵션 2가 그 원칙과 더 잘 맞아 보이지만, 실현 가능성 자체가 아직 검증 안 됨. -## Store/State/Source 온톨로지 — 진행 중인 설계 스레드 (2026-08-04 검증 라운드에서 새로 열림) +## Store/State/Source 온톨로지 — 핵심 메커니즘 확정 (2026-08-04 2차 라운드) -**이 절은 아직 결론난 설계가 아니다** — 검증 라운드 중 사용자가 실시간으로 -설계를 전개하며 나온 내용을 그대로 기록. 다음 세션에서 이어서 다룰 것. -`base/store-semantics.md`의 "State 프리미티브는 실제로 필요하다" 정정과 -직결됨. +**상태**: 전파 모델/`:Compute` 인자 규칙/State 쓰기 금지/Slot 생존 확인/타입 +추론(dot-access) 전부 `AskUserQuestion`으로 확인 완료. 남은 건 정확한 함수/ +생성자 이름뿐(구현 단계). `base/store-semantics.md`의 "State 프리미티브는 +실제로 필요하다" 정정에서 이어짐. -**핵심 온톨로지**: +**핵심 온톨로지** (변경 없음): - **Source** — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). -- **Store** — source들의 집합체. `store.a`/`store "a"`처럼 키로 접근하면 - 그 source를 감싼 **새 State**를 매번 만들어 반환(state가 store에 캐시되어 - 재사용되는 게 아님 — source만 store에 귀속된 유일한 실체). +- **Store** — source들의 집합체. `store.a`처럼 키로 접근하면 그 source를 + 감싼 **새 State**를 매번 만들어 반환(state가 store에 캐시되어 재사용되는 + 게 아님 — source만 store에 귀속된 유일한 실체). - **State** — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 독립적 value 개념이 없음. `state(state)`로 기존 state의 결과를 받아 새 state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 합성 가능"이라는 원래 목표를 구현하는 방식. -- `:With(...)`/`:Compute(fn)`은 실제로는 state 위의 연산 — - `store "key1":With(store "key2"):Compute(function(key1) return key1 + - store.key2.value end)`처럼, with한 값을 fn이 클로저로 직접 읽는 모양. -**미해결 세부 사항**: -- **`:Compute` 캐싱/무효화 전략** — 매번 새로 계산할지, 캐싱해두고 무효화 - 플래그로 관리할지 미정. 후보: 값이 바뀌었는데 듣는 소비자가 없으면 연산은 - 미루고 `invalid=true`만 세워두고, 필요해질 때(듣는 사람이 생기거나 값을 - 읽을 때) 실제로 연산하고 `true`→캐싱 후 `false`로 되돌리는 dirty-flag 방식. - "store가 state를 **만드느냐** 아니면 **저장하느냐**"의 문제와 직결 — - 사용자 판단은 "만든다" 쪽(저장한다고 하면 한 곳에서 `:Compute`를 붙이면 - 다른 소비자도 전부 그 compute된 값을 읽게 되어버리는 오염 문제 발생). -- **`emit` 필요 여부** — store가 값 변경 시 관련된 모든 state에 emit해야 - 하는 구조가 맞는지 확신은 없지만, 그 외의 방법이 안 보인다는 게 사용자 - 현재 판단. `state(from) / state() -> (state, setState)` 같은 팩토리 - 모양도 후보로 언급됨(React의 `useState`류 페어 반환과 유사). -- **Luau 타입 시스템 제약** — `store "key"` 같은 커링 호출로 `state`의 - `T`를 정확히 추론하려면 오버로드 함수 타입(`(("a") -> number) | (("b") - -> boolean)`)이 필요한데, Luau는 문자열 리터럴 인자를 자동으로 `as const` - 취급하지 않아서 타입이 좁혀지지 않는 문제가 있음. `store.states.a`처럼 - 필드 접근으로 우회하거나, `store.a`가 바로 state를 반환하고 - `state.value = x`로 설정 가능하게 하는 대안도 검토됐으나, 후자는 "다른 - source로부터 파생된 state에 value를 직접 설정하면 안 된다"는 문제와 - 충돌(store가 실제 값을 담는 유일한 주체여야 함). `state`(compute - 결과)가 제대로 바인딩 안 됐을 때 생기는 타입 문제는 일단 UB로 두기로 함. -- **`Pipe`(quad2-try 후보)는 사실상 폐기 쪽으로 기움** — 별도 `Pipe` 타입에 - 소유권/버전 가드를 넣어 재설계하는 대신, State 자체를 파이핑 결합체로 - 보고 `state(state)`로 분기하는 쪽이 엔지니어링상 더 쉬워 보인다는 게 - 사용자의 최신 판단(2026-08-03 라운드의 "Pipe COW가 유력 후보"보다 우선함). +**전파 모델 확정: push-invalidate(신호만) / pull-recompute(`Get()` 시점에만) — +Fusion식 eager 노드·생성순 정렬은 안 만듦** + +- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만 + 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 + 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구). +- 신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면 + 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 + 장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책). +- 실제 재계산은 `Get()`(또는 `.value` 인덱싱)이 호출되는 시점에만 일어남 — + "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ + 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 + 소비자가 없다는 판단. 유일하게 "즉시 반응해야 하는" 소비자는 store-bind + pluggable 핸들러(위 "확정된 디스패치 모델" 절)인데, 이건 무효화 신호를 + 받는 즉시 자기가 알아서 `Get()`을 호출해 pull하는 방식으로 충분함 — + State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀 + 필요 없음. +- `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함 + ("emit 필요 여부" 열린 질문은 이걸로 해소). + +**`:With`/`:Compute` — self 인자도 lazy 핸들로 통일** + +- 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 + 단점이 있었음 — self가 raw 값이면 `fn` 호출 전에 항상 self를 먼저 + `Get()`해야 하므로, `fn` 내부 로직이 with한 다른 값을 보고 "이 경우엔 self + 계산 자체가 필요 없다"고 판단해도 이미 늦음(예: `:With(noprint)`이고 + `noprint.value == true`면 앞단 계산을 통째로 생략하고 싶은 경우). +- **해결(사용자 확정)**: self도 raw 값이 아니라 **State 핸들 그 자체**를 + `fn`의 포지셔널 인자로 넘긴다 — `fn(self: State)`, 내부에서 + `self.value`(또는 `self:Get()`)를 실제로 읽을 때만 계산이 트리거됨. + with한 값과 동일한 lazy 원칙을 self에도 그대로 적용 — 별도 + `ComputeWithout` 변형은 불필요, `Compute` 하나로 일관. +- `.value`는 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 + `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴 재사용) — + `:Get()`과 `.value` 둘 다 지원, `.value`가 관용적 표기. +- 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return + key1.value + store.key2.value end)` — `key1`은 이제 raw 숫자가 아니라 + State. + +**State는 쓰기 대상이 아님 — 확정, Source는 독립 공개 프리미티브로 격상** + +- `.value`는 항상 읽기 전용. 값을 쓰는 경로는 오직 Store의 `__newindex` + (`store.key = value`, 이미 확정된 문법)뿐 — State에는 대응하는 쓰기 API가 + 아예 없음. "State에 `.value = x`를 허용하면 다른 source에서 파생된 + state에 직접 쓰기가 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 + 해소(그런 API 자체가 없음). +- **`Source`는 Store의 내부 구현 디테일이 아니라 별도의 가벼운 공개 + 프리미티브로 노출** — Store는 다수의 source를 등록/관리하는 무거운 + 구조라, 값 하나만 반응형으로 다루고 싶을 때 Store를 통째로 만드는 건 + 비효율이라는 게 사용자 판단("store가 source 수십 개 만드는건 비효율이니 + 둘이 다른 구현이라 봐도 될듯"). `Source(initial)` 류의 독립 생성자 + (정확한 이름은 구현 단계에서 확정)가 Store와 나란히 존재. + +**Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정** + +- `base/store-semantics.md`에 있던 "`isInit=false`면 허용, `isInit=true`+ + 생존확인 거짓이면 불허" 분기 초안은 폐기. state-invalidate 리스너 + 클로저도 `base/lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute + predicate)로 등록하면, 발화 시 `canExecute()` 하나만 확인하고 거짓이면 + 그냥 no-op — `isInit` 분기라는 별도 개념 자체가 불필요(사용자 확정: + "canExecute 하나로 통일"). + +**타입 추론 문제 — 확정(2026-08-04 3차 라운드)** + +- `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 + 추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정** + — Store 타입을 `{key: State, other: State}`류 평범한 + 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 + 리터럴 narrowing 문제 자체가 안 생김. `store "key"` 문자열 커링은 동적 + 키가 필요할 때 쓰는 미타입(`State`) 폴백으로 격하. +- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성(`DI.Frame`)/이벤트 + (`On.EventName`)까지 관통하는 프로젝트 전역 관습으로 확정**됨 — 아래 + "인스턴스 생성 / 이벤트 네이밍 인체공학" 절 참고. + +**`Pipe`(quad2-try 후보)는 폐기 확정** — 별도 `Pipe` 타입에 소유권/버전 +가드를 넣어 재설계하는 대신, State 자체가 파이핑 결합체이고 +`state(state)`로 분기하는 위 모델로 완전히 대체됨. + +**PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지** + +`.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/ +Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실제로 다른 +선택이 나와 재검토했으나 결론은 변경 없음: + +- **전파 모델**: PA님의 pub-sub은 push-invalidate가 아니라 **push-값** + (`Event:fire(...)`가 인자를 그대로 콜백에 전달, `Observable`의 `__newindex`가 + 새 값을 실어 즉시 `changed:fire(key, value)`, dirty-flag/`Get()` pull 단계 + 자체가 없음). 한때 "leaf(source 하나→sink 하나, 파생 없음)는 PA님처럼 + push-값으로 단순화하고 push-invalidate/pull-recompute는 실제 `:Compute` + 파생이 있을 때만 쓰자"는 이원화를 검토했으나 **기각** — invalidate+`Get()` + 방식도 leaf에서 딱히 더 복잡하지 않고(불리언 플래그 하나 + `Get()`/`emit` + 둘로 나뉘는 정도), 오히려 두 메커니즘을 병행하면 "leaf State가 나중에 + `:Compute`로 감싸일 때 두 메커니즘을 어떻게 연결하는가"라는 새 경계 문제가 + 생겨 이원화가 더 복잡함. **결정적으로, PA님 코드엔 애초에 `:Compute`/`:With` + 같은 파생·합성 개념 자체가 없음** — quad-v2가 lazy pull을 도입한 이유(여러 + 소비자가 하나의 파생 State를 공유할 때 오염 방지, 안 쓰이는 연산 스킵)를 + PA님 시스템은 처음부터 안 풀려던 문제라, 대등한 반례가 아니었음. **결론: + push-invalidate/pull-recompute로 통일 유지, 변경 없음.** 사용자 최종 확인 + 문구: "store 전파 처리는 우리 방식이 맞음. 이건 vide 에서 없었던것과 + 동일함, [PA님] 저기도 디자인 상 해결 못하는 문제가 된거거든. 비 필요 + 연산과 중복 연산을 지우는건 디자인 단계에서 구성할 일임. 우린 디자인 + 단계부터 해당 문제를 해결하고 싶었던거야." +- **라이프사이클**: PA님 코드는 GC-native가 아니라 **전부 수동 해제** + (`Connection.connected`는 계산 속성이 아니라 저장된 bool, `Observer`의 + 8개 `subscribeXxx` 헬퍼 전부 명시적 `:unsubscribe()` 필요, weak table은 + `Observable`의 subject↔observable 캐시 한 곳뿐). rbvm 기반으로 확정한 + "GC 위임, 명시적 dispose 없음" 원칙과 반대 선택이라 재확인 질문했으나, + **GC-native 유지로 확정** — 지금까지 이 정도 규모(명시적 dispose가 꼭 + 필요할 만큼 큰 자원)를 요구하는 실제 사례가 없었다는 게 사용자 판단. 다만 + **완전히 막다른 길은 아님**을 기록해둠: rbvm처럼 관계를 양쪽 다 weak-keyed로 + 두고 모든 걸 connection 람다에 담아 "연결이 살아있는 동안만 살아있게" 하는 + 방식이면, 나중에 GC만으로 정말 부족한 케이스가 생겨도 그 connection을 얻어 + `disconnect()`하는 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인 — + 지금 마일스톤에서는 필요 없어서 안 함(사용자: "필요하다면 dispose 핸들러를 + 만들어주는 것도 가능한 디자인, 다만 지금까지 요구가 없었음"). ## quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지 @@ -256,7 +346,7 @@ State/스트림)를 다뤘던 이전 시도가 있었음. 조사 결과 요약: - **Slot은 이 시도에서도 사실상 빈 스텁**이었음 — `Insert`의 실제 구현부가 전부 주석 처리되어 있고, `Notify()`도 빈 함수. 심지어 구 v1(`quad-2`)의 `DEV_CHANGELOG.txt`에도 "TODO: slot 기능 구현"이 마지막까지 미완료로 남아있었음 - — **가져올 게 전혀 없음**, `research/slot-plan.md`의 from-scratch 설계를 + — **가져올 게 전혀 없음**, `base/slot-plan.md`의 from-scratch 설계를 그대로 진행하면 됨(재조사 불필요). - 다른 서브패키지(`quad-roblox`/`quad-gtk`/`quad-lang`/`quad-gen`/`quad-compat`/ `quad-debug`/`quad-docs`)는 전부 파일이 0개인 빈 디렉토리 — `quad-core` 밖엔 @@ -315,38 +405,97 @@ copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기 RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 `quad` 패키지로 재수출할 필요 없음, 필요하면 만들어도 됨). -**열린 질문**: `RobloxFactory`를 같은 `BaseModule`에 여러 번 호출하면 어떻게 -되어야 하는가 — 이미 초기화됐으면 무시(rbvm의 `InitNamespace`류 가드와 -유사하되, "라이브러리마다 수동 init" 패턴과는 다름)하는 쪽으로 기울어짐. -서로 다른 두 곳에서 같은 `base`를 require해서 `RobloxFactory`와 -`AnotherFactory`(가상의 예)를 각각 실행하는 경우처럼 충돌 가능성이 있는 -시나리오가 향후 모듈 스코핑(`New()`, `base/architecture.md` 13번) 논의를 -다시 촉발할 수 있음 — 지금은 열어만 둠. +**확정(2026-08-04 3차 라운드)**: `RobloxFactory`를 같은 `BaseModule`에 여러 +번 호출했을 때 — **같은 팩토리로 재호출하면 무시(no-op)**, hot-reload처럼 +초기화 스크립트가 다시 도는 경우를 안전하게 만듦. **다른 팩토리 +(`AnotherFactory` 등, 가상의 예)로 재호출하면 에러** — 이건 `base/module-lifecycle-plan.md`의 "bind는 유일 슬롯" 원칙(이미 구현체가 있는데 또 +다른 구현체로 init하려 하면 오류)이 다루던 것과 정확히 같은 케이스, 이 +문서의 이전 "무시" 잠정안과 그 문서의 "오류" 잠정안이 서로 모순되는 게 +아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는 +서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가 +초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만 +두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 실은 +열려있던 게 아니라 자연히 풀림 — `New()`가 생기면 각 인스턴스가 별도 +테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요. -## 인스턴스 생성 / 이벤트 네이밍 인체공학 (2026-08-04 검증 라운드에서 새로 나온 열린 질문) +## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨) `Quad "Frame"`처럼 문자열로 인스턴스 종류를 지정하는 방식은 타입 추론이 -어려움(위 온톨로지 절의 Luau 오버로드 문제와 같은 원인). PA님 DI 스타일은 -`DI.Frame`/`DI.TextLabel`처럼 필드 접근으로 만들어서 자동완성이 자연스럽게 -됨 — 목록에 없는 타입은 `DI.New<> "Frame"`류로 폴백. 이벤트도 -`Event ""` 대신 `On...`류 이름으로 필드 접근하면 타입/자동완성이 쉬워질 수 -있음. 아직 방향 결정 안 됨 — 다음 세션에서 다룰 것. +어려움(위 온톨로지 절의 Luau 오버로드 문제와 같은 원인). 사용자가 실제 +참고 코드를 `.claude/initreq/artworks/DeclarativeProgramming/ +DeclarativeInstance.luau`(PA님 작성, UI 포함 전반적 설계 패턴을 시범 적용한 +데모 모듈)에 공유해줘서 직접 확인 — **"DI"는 Dependency Injection이 아니라 +"Declarative Instance"(선언형 인스턴스 생성)**. + +**인스턴스 생성 — PA님 코드 그대로 채택**: 처음 제안했던 "필드=1급 타입 +경로, 문자열=폴백"이라는 2트랙(`DI.Frame` vs `DI.New<> "Frame"`) 구상 +보다 실제로는 더 단순했음(`DeclarativeInstance.luau:104-160`) — +**제네릭 생성자 함수 하나(`new(className): from>`)가 알려진 타입과 모르는 타입을 전부 커버**하고, 그중 UI에서 자주 +쓰는 클래스 ~25개(`Frame`/`TextButton`/`UICorner` 등, `UIInstances` 타입 +테이블에 등록된 것들)만 모듈 로드 시점에 **즉시(eager)** `constructor.Frame = +new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지연 생성이 +아니라 그냥 정적 테이블. quad-v2도 이 모양 그대로 채택: 하나의 제네릭 +생성자 + 자주 쓰는 것만 정적으로 미리 바인딩. + +**이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열 +키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91`의 +`assign(instance, key, value)`가 `ReflectionService:GetPropertiesOfClass`/ +`GetEventsOfClass`로 클래스별 프로퍼티/이벤트 타입을 캐싱해두고, 키가 +`RBXScriptSignal` 타입이면 자동으로 `instance[key]:Connect(value)`로 처리함 +— `Frame { MouseButton1Click = fn }`처럼 별도 네임스페이스 없이 그냥 문자열 +키로 씀. 이건 타입 안전성을 어느 정도 포기하는 대가지만(콜백 시그니처까지 +Luau가 검증 못 함 — `apply(instance: T, properties: U): T & U`가 스키마 +검증 없이 구조적으로만 merge), 이미 UB로 남긴 "테이블 리터럴 안 키별 값 +타입 자동 검증 불가"와 같은 급의 한계라 손해가 크지 않고, `On.` 접두어 없이 +문법이 더 간결해짐 — **사용자 확정**("PA 님 방식 괜찮은듯. 타이핑은 인라인이 +되긴 하겠지 정도면 괜찮다"). quad-v2 구현에서는 이 "키가 이벤트인가" +판별을 `isHandlable`로 감싼 pluggable 핸들러(`quad-roblox`가 `Reflection +Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 구조 자체가 +불필요해짐. + +**Store 쪽 dot-access는 그대로 유지**: `store.key`(1급 타입 경로)/ +`store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가 +타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: State, ...}`류 +평범한 레코드 타입으로 지어짐) 그대로 유지 — 이벤트만 예외였을 뿐, "정적으로 +알려진 것=필드 접근" 원칙 자체가 깨진 건 아님. + +**PA님 코드와 대조해서 재확인한 것(변경 없음)**: +- **OOP 회피 결정은 오히려 보강됨** — PA님의 `ObjectOrientedProgramming/ + class.luau`도 `setmetatable(methods, {__index = parent})` 체이닝 상속이라 + quad-v2가 피하기로 한 quad2-try `Base:Extends`와 같은 모양이고, 제네릭을 + 파일마다 중첩해서 재선언해야 하는 보일러플레이트까지 동일하게 나타남. +- **Instance 태그는 CollectionService 직접 사용 그대로 유지** — PA님의 + `EventDrivenProgramming/Observer.luau`의 `subscribeTaggedInstance`도 얇은 + `CollectionService` 래퍼일 뿐. `DataOrientedProgramming/TagService.luau`는 + 이것과 무관하게 plain-table 엔티티(비-Instance 데이터)용 커스텀 태그 + 인덱스라 지금 quad-v2 스코프 밖 — Instance가 아닌 데이터에 태깅이 필요해질 + 미래 시나리오를 위한 참고 자료로만 기록. +- **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지** + (아래 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). ## 남은 열린 질문 (`.claude/question.md`에도 취합) -- **`:Compute`가 `with`한 값을 정확히 어떻게 읽는가** — 클로저로 직접 캡처하는 - 방향은 확정(위 온톨로지 절), 캐싱/무효화 전략(dirty-flag 등)과 `emit` 필요 - 여부는 아직 미정. +이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State +쓰기 금지/Slot 생존 확인 → dot-access 타입 추론/인스턴스·이벤트 네이밍/ +`RobloxFactory` 재호출 가드)를 거치며 전부 확정됨. 남은 건 순수 API 표면 +이름뿐: + +- **`state()`/`Source()`/`Get()`/`DI`(또는 다른 이름) 등 정확한 함수·생성자· + 모듈 이름** — 방향은 전부 확정, 이름만 구현 단계에서 남음(`On` 모듈은 + 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 불필요해짐 — 위 "인스턴스 생성 / + 이벤트 네이밍" 절 참고). - **`CreatedRef`(가칭)의 정확한 함수/옵션 이름** — children 배열에 아이템으로 넣는다는 방향과 생성/마운트 두 시점 모두 지원한다는 것은 확정, 정확한 API 이름만 남음. - **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상). -- Store가 Store를 담는 경우의 실제 소유권(누가 내부 Store를 destroy하는가) — - 이 문서의 "재실행 래핑" 제안이 맞다면 자연히 바깥 Store bind가 내부 Store의 - 라이프타임도 감싸게 될 텐데, 이중 해제(double-dispose) 방지가 필요한지 확인. - 단, `base/lifecycle-pattern.md`의 "destroy 시점엔 아무것도 안 함" 원칙상 이중 - 해제 자체가 걱정할 필요 없는 개념일 수도 있음 — 재검토 필요. -- `RobloxFactory` 중복 호출/충돌 시나리오, 인스턴스 생성·이벤트 네이밍 - 인체공학 — 위 두 절 참고, 둘 다 새로 열린 질문. + +**해소된 것**: "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 +필요한가"는 재검토 결과 질문 자체가 성립 안 함으로 결론 — State/Source +그래프 구독이 전부 weak-keyed GC-native(명시적 `dispose()` 호출이 아예 없음, +`base/lifecycle-pattern.md`의 GC 위임 원칙 재사용)라 "같은 걸 두 번 해제"할 +행위 자체가 존재하지 않음(GC는 멱등). "`:Compute`가 with한 값을 어떻게 +읽는가"/"emit 필요 여부"도 전파 모델 확정으로 해소, `RobloxFactory` 중복 +호출/충돌 시나리오·인스턴스 생성/이벤트 네이밍도 위 절에서 전부 확정. diff --git a/.claude/base/comparison-fusion-vide.md b/.claude/base/comparison-fusion-vide.md index d47740e..5a90494 100644 --- a/.claude/base/comparison-fusion-vide.md +++ b/.claude/base/comparison-fusion-vide.md @@ -51,12 +51,12 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 인용되는 원본 비교 자 |---|---|---|---| | 전파 모델 | push+pull 하이브리드, eager 집합만 즉시 재계산, 생성순 정렬로 글리치 방지 | 순수 push, 즉시 동기 재평가, 다이아몬드 중복 재평가 미해결(저자 인정) | "Store는 값 자체에 항상 eager 발화, cleanup이 key/value를 먼저 확인" 요구사항은 Vide의 push 모델 + eval-전-cleanup 패턴에 더 가까움. 단 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의 "등록 없이 태그로 인식" 인체공학을 합치되, 우선순위 축은 열린 숫자 공간으로 일반화해야 함(`research/bind-system-plan.md`). | +| 키/값 디스패치 개방성 | SpecialKey 모양은 열려있으나 우선순위 4단계 하드고정 | action()은 등록 없는 태그 인식 방식이지만 key/value 버림, 콜백+우선순위만 | quad는 Fusion의 "디스패처가 key+value+target을 다 받는" 풍부함과 Vide의 "등록 없이 태그로 인식" 인체공학을 합치되, 우선순위 축은 열린 숫자 공간으로 일반화해야 함(`base/bind-system-plan.md`). | ## 추가로 기록해둘 것 - Vide의 암묵적(ambient stack) 의존성 추적 vs Fusion의 명시적 `use()` 축은 - push/pull 축과 독립적인 별개 결정 — quad는 아직 미정(`research/bind-system-plan.md` + push/pull 축과 독립적인 별개 결정 — quad는 아직 미정(`base/bind-system-plan.md` 열린 질문 참고). Fusion의 명시적 `use()`는 `checkLifetime` 같은 bind-time 체크를 가능하게 하는 부수 효과가 있음. - 두 라이브러리 다 mount 시 단일 소유권 가드가 없다는 것 자체가 quad Slot의 diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index b1765a8..882c996 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -71,7 +71,7 @@ rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private - `InitNamespace`/`Registered`-가드/`NewLib` 3종 세트로 "라이브러리마다 하나하나 수동 init" 하는 방식은 정확히 사용자가 피하고 싶다고 한 패턴 — 순서 있는 dispose-hook 리스트 자체는 재사용해도, 수동 init 관례는 그대로 베끼지 말 것 - (팩토리 함수로 대체 — `research/module-lifecycle-plan.md` 참고). + (팩토리 함수로 대체 — `base/module-lifecycle-plan.md` 참고). ## 확정: Signal 클래스는 안 만든다 @@ -97,7 +97,7 @@ Destroy되면 그 대상에 묶인 것들(Tween 등)도 자연히 죽은 상태 이 원칙 때문에 "값 교체 시 이전 처리를 무르는 것"(아래 `retract`)과 "완전 소멸 시 정리"는 **하나로 통일** — 후자는 애초에 안 만듦. `research/ -tween-plan.md`/`research/slot-plan.md`의 "cleanup" 표기는 전부 `retract`로 +tween-plan.md`/`base/slot-plan.md`의 "cleanup" 표기는 전부 `retract`로 갱신됨(이름 변경 근거는 아래). ## 함수 안에서 만든 옵저버도 GC 대상이 되어야 함 — 범용 "생명 바인드 유틸" 필요 @@ -118,11 +118,29 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 가질 수 있어서, `Connected`가 false면 실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). -이건 `research/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을 +이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"(weak-keyed per-instance 저장소), 다른 하나는 "언제까지 실행되어도 되는지"(생명 바인드 + canExecute)를 다룸. 둘 다 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가 인터페이스로만 diff --git a/.claude/research/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md similarity index 85% rename from .claude/research/module-lifecycle-plan.md rename to .claude/base/module-lifecycle-plan.md index aa25af0..927b691 100644 --- a/.claude/research/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -1,7 +1,8 @@ -# 모듈 라이프사이클 — 프로바이더 패턴, bind/store는 누가 구현하는가 (착수 전) +# 모듈 라이프사이클 — 프로바이더 패턴, bind/store는 누가 구현하는가 (base로 승격됨) -**상태**: research — 방향은 있지만 "누가 store를 구현하는가"는 사용자 스스로 -"진짜 애매한 지점"이라고 남긴 미해결 항목. 원본: +**상태**: base — "누가 store를 구현하는가"까지 포함해 전부 확정되어 +`research/`에서 승격됨(`base/architecture.md`의 "구현 착수: 소스 트리 구조 +확정" 절 참고). 원본: `.claude/initreq/raw-userinput.md` "넘버 바인드는 누가 처리?" / "모듈은 스코핑 되는가" / "pluggable 하다면 해당 플러그를 초기화하는 건 누구 몫?" / "다시 돌아와서… bind는 누가 어떻게 구현" / "스토어는 누가 구현해…" 절. 확정된 상위 결정은 @@ -32,7 +33,7 @@ RBVM처럼 `init namespace` 하나하나 부르는 방식은 별로(`base/lifecy 인터페이스 상 `bind`를 두고 이것도 pluggable하게 할지 고민 — 단 **1개만 존재할 수 있는 형태**로 구현하는 게 맞다고 기울어짐: 이미 bind 구현체가 있는데 또 init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류. 즉 "pluggable -슬롯이지만 유일하게 채워질 수 있는 슬롯" — 위의 `research/bind-system-plan.md`가 +슬롯이지만 유일하게 채워질 수 있는 슬롯" — 위의 `base/bind-system-plan.md`가 말하는 "여러 핸들러가 우선순위로 경쟁"하는 것과는 다른 층위: **핸들러 레지스트리 자체(그 배후의 실제 bind 구현/백엔드)는 유일해야 하고, 그 안에 등록되는 개별 핸들러들은 여럿+우선순위 경쟁이 맞는 모양.** @@ -46,8 +47,7 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 계산 속성)를 소유하는 게 맞다고 확정. 추가로 명확해진 것 — **store 바인드가 수행하는 "처리된 값을 다시 `process(inst,k,realv)`로 넘기는" 재실행 로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 재구현하면 안 -됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → `research/ -bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 제공 로직. +됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → `base/bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 제공 로직. 부수적으로 확인된 것: - **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은 @@ -55,8 +55,7 @@ bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 일반 함수를 받는 형태로 통일(`base/store-semantics.md` 참고). "너무 verbose한 연산들은 오히려 일관성을 해친다"는 게 이유. - **여러 store 값을 묶어 유연하게 처리하는 방법**(`useEffect`류 dependency - array)은 있으면 좋겠다는 요청 — API 시그니처는 미정, `research/ - bind-system-plan.md`의 남은 열린 질문 참고. + array)은 있으면 좋겠다는 요청 — API 시그니처는 미정, `base/bind-system-plan.md`의 남은 열린 질문 참고. - `can execute store bind` 후킹 자체는 `Connected` 계산 속성으로 대체된다는 잠정 제안이 그대로 유지되고, 여기에 더해 **완전 소멸(Destroy) 시점엔 아무 처리도 필요 없다**는 원칙까지 확정됨(`base/lifecycle-pattern.md`) — 즉 이 @@ -90,7 +89,10 @@ bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base pluggable 참가자라는 점은 확정, 이름만 미정. - base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 - 주입한다는 패턴이 확정됨 — 상세는 `research/bind-system-plan.md`의 "base - 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. 이 패턴을 - 중복 호출했을 때의 가드 동작(멱등 처리)과 모듈 스코핑(`New()`)의 관계는 - 여전히 열려있음. + 주입한다는 패턴이 확정됨 — 상세는 `base/bind-system-plan.md`의 "base + 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출 + 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 + 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — + 바로 아래 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 + 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 + 스코핑됨, 별도 재설계 불필요. diff --git a/.claude/base/quad-v1-architecture.md b/.claude/base/quad-v1-architecture.md index 3e699b1..322e1c1 100644 --- a/.claude/base/quad-v1-architecture.md +++ b/.claude/base/quad-v1-architecture.md @@ -68,12 +68,12 @@ v2에서 태그 시스템으로 대체 예정(`base/store-and-tags.md` 참고). 1. Metatable 체이닝으로 "불변 빌더" 흉내내기 → 대신 팩토리 함수로 필요한 곳만 복사 (`raw-userinput.md` "복사 구현은 지양" 항목, `.claude/initreq/raw-userinput.md:83-86`). 2. 하드코딩된 중앙 디스패처 → pluggable `isHandlable(key,value)` + 우선순위 핸들러 - 레지스트리 (`research/bind-system-plan.md`). + 레지스트리 (`base/bind-system-plan.md`). 3. 흩어진 "GC 안 되게 참조 붙잡기" 핫팩 → rbvm 스타일 `Connected` 계산 속성 + 명시적 라이프타임 홀더 (`base/lifecycle-pattern.md`). 4. mount가 여러 책임(부모 부기+파괴+child 레지스트리)을 한 모듈에 다 지는 구조 → Slot이 child CRUD를 전담, mount는 단일-마운트 강제만 전담 - (`research/slot-plan.md`). + (`base/slot-plan.md`). 5. tracker.lua, lang.lua 내장 → 둘 다 라이브러리 범위 밖으로 분리(스토리북/ 외부 로케일 라이브러리에 위임). diff --git a/.claude/research/slot-plan.md b/.claude/base/slot-plan.md similarity index 73% rename from .claude/research/slot-plan.md rename to .claude/base/slot-plan.md index d8cd338..ec266e6 100644 --- a/.claude/research/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1,10 +1,27 @@ -# Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (착수 전) +# Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨) -**상태**: research — 설계 방향은 상당히 잡혀 있으나 세부(특히 소유권 이전/해제 -시맨틱)는 사용자와 확인 필요. 원본: `.claude/initreq/raw-userinput.md` "slot을 -구현하도록 하기로 했음" 절. Fusion의 `Children` SpecialKey와 Vide의 mount 무가드 -비교는 `base/comparison-fusion-vide.md` 참고 — 결론: **두 라이브러리 어디에도 -이런 엄격한 단일 마운트 가드가 없음, quad의 진짜 개선점.** +**상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, retract=폐기)과 +소스 트리 상 패키지 경계까지 확정되어 `research/`에서 승격됨(`base/ +architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 원본: +`.claude/initreq/raw-userinput.md` "slot을 구현하도록 하기로 했음" 절. Fusion의 +`Children` SpecialKey와 Vide의 mount 무가드 비교는 `base/comparison-fusion-vide.md` +참고 — 결론: **두 라이브러리 어디에도 이런 엄격한 단일 마운트 가드가 없음, +quad의 진짜 개선점.** + +## 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들을 담게 될 것으로 취급. + +**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미 +만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예: +`Frame { Frame {} }`)를 위한 핸들러도 필요 — `quad-roblox/src/Handlers/ +InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "정적으로 +하나 박아넣는" 더 단순한 경우라 별개로 둠. ## 개념 @@ -57,11 +74,11 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract` `process`하면 되므로, 부모에게 위임(자식 slot 요소 자체가 스스로 정리를 실행하는 게 아니라). -이건 `research/bind-system-plan.md`의 "Store 바인드는 재실행 래핑" 확정 +이건 `base/bind-system-plan.md`의 "Store 바인드는 재실행 래핑" 확정 모델과 맞물림 — slot이 store 값으로 오면, store 바인드 핸들러가 이전 slot 상태를 `retract`하고 새 slot 상태로 다시 `process`하는 사이클을 돈다는 뜻. Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는 -추적(구독)도 `research/bind-system-plan.md`가 말하는 "process 함수가 다른 값 +추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값 변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 — Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도 동일하게 적용. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 2823c4d..63b3b42 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -1,8 +1,7 @@ # Store 의미론 — 부작용 허용, State는 Store 위의 조합 가능한 캐시 레이어 **상태**: base — 부작용 허용/Store 문법 부분은 확정. State/Source 온톨로지는 -2026-08-04 검증 라운드에서 새로 열린 진행 중인 설계 스레드(`research/ -bind-system-plan.md` 참고). 원본: `.claude/initreq/raw-userinput.md` +2026-08-04 검증 라운드에서 새로 열린 진행 중인 설계 스레드(`base/bind-system-plan.md` 참고). 원본: `.claude/initreq/raw-userinput.md` "store는 부작용을 허용함" / "state는 어떻게 구현하는가" 절. ## Store는 부작용을 허용하는 게 기본 디자인 @@ -26,17 +25,23 @@ purity-and-effects-plan.md`와 연결됨). 막을 수는 없지만, 라이브러리로 재사용하려는 컴포넌트가 이런 부작용을 가지면 이식성이 떨어짐(`research/purity-and-effects-plan.md`와 연결). -**미해결 열린 질문**: state를 옵저빙해서 나온 결과로 slot에 `clear`/`add` 같은 -연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면 어떻게 되는가 — state가 -생성되는 지점과 slot이 적용되는 지점이 서로 다른 스코프라, "state 변경이 -발생했을 때 그 slot이 아직 살아있는지"를 어떻게 연관지어 확인할지 아직 명확한 -설계가 없음(`isInit=false`일 때는 허용, `isInit=true`이고 생존 확인 함수가 -거짓이면 불허 정도의 방향은 있으나 미완성). 사용자 본인도 "더 리서치가 필요" -하다고 명시적으로 표시 — `research/bind-system-plan.md`의 "Store/State/Source -온톨로지" 스레드와 함께 다룰 것. +**해소됨(2026-08-04 2차 라운드)**: state를 옵저빙해서 나온 결과로 slot에 +`clear`/`add` 같은 연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면 +어떻게 되는가 — 별도 메커니즘을 새로 만들 필요 없이, `base/ +lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute predicate)을 state- +invalidate 리스너 클로저 등록에도 그대로 재사용하면 됨: 발화 시 +`canExecute()` 하나만 확인, 거짓이면 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 프리미티브는 만들지 않는다")은 틀렸음 — 사용자가 검증 라운드에서 직접 정정.** 정확한 모델: @@ -54,7 +59,7 @@ purity-and-effects-plan.md`와 연결됨). - 이건 quad2-try(폐기된 이전 시도)의 `Pipe` copy-on-write 절충안을 대체하는 방향으로 좁혀짐 — 별도 `Pipe` 타입을 만들어 소유권/버전 가드를 넣는 대신 State 자체가 "파이핑 결합체"이고 `state(state)`로 분기하면 될 걸로 보임 - (`Pipe` 후보는 사실상 폐기 쪽으로 기움). 상세는 `research/bind-system-plan.md`의 + (`Pipe` 후보는 사실상 폐기 쪽으로 기움). 상세는 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고 — **아직 완전히 결론난 설계는 아니고, 구현 단계에서 더 다뤄야 할 진행 중인 스레드.** @@ -62,7 +67,7 @@ purity-and-effects-plan.md`와 연결됨). 없으면 연산을 미루는 dirty-flag 방식 등), Luau 타입 시스템에서 `store "key"` 같은 커링 호출이 오버로드 함수 타입으로 `state`를 정확히 추론하기 어려운 문제(문자열 리터럴이 as-const로 좁혀지지 않는 문제) — 둘 다 열린 채로 -`research/bind-system-plan.md`에서 계속 다룰 것. +`base/bind-system-plan.md`에서 계속 다룰 것. ## Store 값 설정 문법 — v1 인체공학 유지 (확정) @@ -82,12 +87,13 @@ purity-and-effects-plan.md`와 연결됨). 인체공학(사용자가 좋아하는 부분)은 유지하되 내부 구현(체이닝이 아니라 팩토리 함수)만 바꾼다. -## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 연구 필요 +## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 `useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 -요구가 있음(v1의 `myStore "a,b"` 콤마-조인 문자열 방식은 폐기 대상 — -`base/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). 단, **v1의 -`:Add`/`:With`/`:Tween` 같은 이름 붙은(named) 체이닝 연산은 만들지 않기로 -확정** — 대신 일반 함수를 받아 처리하는 쪽이 일관적이라는 판단. 구체적인 API -모양(`Store.Combine({a,b}, function(a,b) ... end)`류)은 아직 미정 — -`research/bind-system-plan.md` 참고. +요구가 있었음(v1의 `myStore "a,b"` 콤마-조인 문자열 방식은 폐기 대상 — +`base/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). **v1의 +`:Add`/`:With`/`:Tween` 같은 이름 붙은(named) 체이닝 연산은 만들지 않음** — +대신 일반 함수를 받아 처리. 최종 형태는 `:With(...)`로 의존성을 모으고 +`:Compute(fn)`으로 파생 State를 만드는 것으로 확정 — `Store.Combine({a,b}, +fn)`류 포지셔널 인자 방식은 기각됨. 정확한 lazy 인자 규칙(self/with 값 둘 다 +State 핸들로 넘기고 `.value`를 실제로 읽을 때만 계산)은 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. diff --git a/.claude/question.md b/.claude/question.md index 25e52e5..bd2dee3 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -5,6 +5,31 @@ 있음. 사용자가 Lua/Roblox 엔진에 대해 깊이 아는 사람이라는 전제로, 우선순위 높은 것부터 정렬. +## 2026-08-04 5차 라운드 완료 — 소스 트리 구조 확정 + +`.claude/base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고. +`base/bind-system-plan.md`/`base/module-lifecycle-plan.md`/`base/slot-plan.md`가 +이 라운드에서 `research/`에서 승격됨. + +- **패키징**: 최종 목표는 다중 wally 패키지지만, 지금 Luau 툴링(wally 타입 + 단절, `luau-lsp` 심볼릭 링크 해석 문제)이 불안정해서 당장은 모놀리식 — + `Sleitnick/RbxUtil` 패턴(루트 통합 개발/테스트, 서브폴더마다 자체 + `wally.toml`) 채택. `.luaurc` alias는 런타임 require에서 아직 엔진 미지원 — + 편집기 경험용으로만 사용, 런타임 require는 상대경로. +- **패키지 경계**: `quad-base` = Store/State/Source 온톨로지+전파 **+** + pluggable 디스패치 엔진(`process`/`retract`, 핸들러 계약, `LifetimeHandle`/ + `PerInstanceState` 인터페이스, Ref, Slot 코어 재조정 로직) — 전부 + "인터페이스"로, 다른 엔진(GTK 등)에서도 재사용 가능해야 한다는 전제. + `quad-roblox` = 위 인터페이스의 실제 구현체(`RobloxFactory`, Property/Event/ + Attribute/Tag/Tween/Slot 적용 핸들러, `DI` 인스턴스 생성자) — 이유: 엔진마다 + 큰 구현을 중복하지 않기 위함(rbvm의 relation 통합 시도와 같은 동기). +- **Slot 패키지 경계**: 재조정 로직(add/remove/clear)은 base, 실제 Instance + `Parent`/`Destroy` 조작은 roblox의 핸들러가 담당 — `base/slot-plan.md` + "base/roblox 패키지 경계" 절. +- **새 핸들러 필요성 확인**: `k:number, v:Instance`(중첩 인스턴스를 직접 + 자식으로 넣는 경우, `Frame { Frame {} }`)를 위한 `InstanceChild` 핸들러가 + Slot과 별개로 필요 — `quad-roblox/src/Handlers/InstanceChild.luau`. + ## 2026-08-04 검증 라운드 완료 아래 "확정됨" 절 전체(architecture.md 14개 항목, lifecycle-pattern.md, @@ -18,51 +43,100 @@ slot-plan.md, tween-plan.md)를 `AskUserQuestion`으로 하나씩 예/아니오 전체가 새로운 열린 설계 스레드로 떠올랐음** — 아래 "최우선 새 열린 질문" 참고. - Slot의 `retract` 동작이 "부모 위임" 잠정안에서 "폐기(옮기지 않음)"로 확정 - — `research/slot-plan.md`. + — `base/slot-plan.md`. - quad2-try의 `Pipe` copy-on-write 후보는 사실상 폐기, `state(state)` 조합 - 모델로 대체 — `research/bind-system-plan.md`. + 모델로 대체 — `base/bind-system-plan.md`. - `Connected` 체크/GC 위임/`Destroying` 훅 관련 뉘앙스 보강(엔진별 인터페이스 주입, quad는 rbvm보다 즉시정리 필요성이 낮음) — `base/lifecycle-pattern.md`. - base 유틸(per-instance 저장소, 생명 바인드)은 인터페이스만, 실제 구현은 - `RobloxFactory(BaseModule)`류 백엔드 팩토리가 주입 — `research/ - bind-system-plan.md`. + `RobloxFactory(BaseModule)`류 백엔드 팩토리가 주입 — `base/bind-system-plan.md`. ## 최우선 새 열린 질문 (검증 라운드에서 새로 터져나옴) -- **Store/State/Source 온톨로지 전체** — store는 source 집합체, state는 - source를 감싸는 조합 가능한 캐시(자기 고유 value 없음), `state(state)`로 - 분기. `:Compute`의 캐싱/무효화 전략(dirty-flag 등), `emit` 필요 여부, Luau - 타입 시스템에서 `store "key"` 커링 호출의 `state` 추론 문제까지 전부 - 미정 — 다음 세션 최우선 논의 대상. → `research/bind-system-plan.md`의 - "Store/State/Source 온톨로지" 절. -- **부작용이 slot 생존 여부와 어떻게 연관되는가** — state 옵저빙 결과로 - slot을 조작할 때, 그 시점에 대상 slot이 죽어있으면 어떻게 처리할지 사용자도 - 아직 명확한 답이 없다고 명시. → `base/store-semantics.md`. -- **인스턴스 생성/이벤트 네이밍 인체공학** — `Quad "Frame"` 문자열 방식 vs - `DI.Frame` 필드 접근 방식(자동완성/타입추론 트레이드오프). → `research/ - bind-system-plan.md`. -- `RobloxFactory` 같은 백엔드 팩토리를 같은 base에 중복 호출했을 때의 가드 - 동작, 모듈 스코핑(`New()`)과의 관계. → `research/bind-system-plan.md`. +**전부 확정됨** — 아래 "2026-08-04 3차 라운드" 절 참고. 이 섹션에 새 항목이 +생기면 여기 추가. + +## 2026-08-04 4차 라운드 완료 — PA님 실 코드(`initreq/artworks`) 교차검증 + +사용자가 실제 참고 코드를 공유(`.claude/initreq/artworks/`, PA님 작성) — +아래 두 항목이 3차 라운드 잠정안에서 정정됨, 나머지는 재검토 후 기존 확정 +유지: + +- **"DI" = Declarative Instance**(Dependency Injection 아님) — 3차 라운드의 + 오해 정정. +- **이벤트 바인딩 정정**: `On.EventName` 도트액세스 안 씀 — PA님 방식(평범한 + 문자열 키 + `ReflectionService` 기반 자동 판별, `Frame { MouseButton1Click + = fn }`)으로 전환. Store의 `store.key`는 실질적 타입 이득이 있어 dot-access + 유지, 이벤트만 예외. +- **인스턴스 생성**: 2트랙(`DI.Frame`/`DI.New<>`) 대신 PA님 코드처럼 + 제네릭 생성자 함수 하나 + 자주 쓰는 클래스만 정적 필드로 미리 바인딩하는 + 더 단순한 모양으로 정정. +- **전파 모델(push-invalidate/pull-recompute)·라이프사이클(GC-native)은 + 재검토 후 기존 확정 유지** — PA님 코드가 반례처럼 보였으나(전자는 push-값 + 단순 pub-sub, 후자는 전부 수동 해제) 대등한 비교가 아니었거나(파생/합성 + 개념 자체가 없음) 지금 필요성이 없다는 게 사용자 판단. 라이프사이클은 + 나중에 하이브리드로 확장 가능한 여지만 기록. +- OOP 회피 결정은 PA님의 `class.luau`도 같은 체이닝 상속 보일러플레이트를 + 보여 오히려 보강됨. Instance 태그는 CollectionService 직접 사용 유지. + +→ 상세: `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 +인체공학" 절, "Store/State/Source 온톨로지"의 "PA님 코드와의 교차검증" 절, +`base/lifecycle-pattern.md`의 "교차검증" 절. + +## 2026-08-04 3차 라운드 완료 — dot-access 관습 확정, RobloxFactory 가드 확정 (일부 4차 라운드에서 정정됨) + +- **dot-access를 프로젝트 전역 관습으로 확정**: "정적으로 알려진 것=필드 + 접근, 동적인 것=문자열 호출 폴백"이 Store(`store.key`/`store "key"`)와 + 인스턴스 생성에 적용됨 — **이벤트는 4차 라운드에서 예외로 정정**(위 참고). +- **`RobloxFactory` 재호출 가드 확정**: 같은 팩토리로 재호출 시 무시 + (no-op, hot-reload 안전), 다른 팩토리로 재호출 시 에러(유일 슬롯 충돌). + `New()`와는 인스턴스별 테이블 분리로 자연히 공존 — 재설계 불필요. (4차 + 라운드에서 변경 없음) + +→ 상세: `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 +인체공학" 절, "base 유틸은 인터페이스..." 절의 재호출 가드 부분. + +## 2026-08-04 2차 라운드 완료 — Store/State/Source 온톨로지 핵심 메커니즘 + +위 최우선 질문 중 "Store/State/Source 온톨로지 전체"와 "부작용이 slot 생존 +여부와 어떻게 연관되는가"는 `AskUserQuestion`으로 확인 완료, 더 이상 열려있지 +않음: + +- **전파 모델**: push-invalidate(신호만, 값 안 실음) / pull-recompute(`Get()` + 시점에만 재계산) — Fusion식 eager 노드·생성순 정렬은 안 만듦. +- **`:Compute`의 self 인자**: raw 값이 아니라 State 핸들 자체를 넘겨서 self도 + with한 값과 동일하게 lazy하게(`.value`를 실제로 읽을 때만 계산) 처리 — + 별도 `ComputeWithout` 불필요. +- **State는 쓰기 대상이 아님**: `.value`는 읽기 전용, 값 쓰기는 항상 Store의 + `__newindex`로만. `Source`는 Store 내부 디테일이 아니라 값 하나만 다룰 때 + 쓰는 별도의 가벼운 공개 프리미티브로 격상. +- **Slot 생존 확인**: 별도 메커니즘 없이 기존 "생명 바인드 유틸"의 + `canExecute`로 통일 게이트. +- **`store.key` dot-access 타입 추론 제안**: 3차 라운드에서 정식 확정됨(위 + "2026-08-04 3차 라운드 완료" 절 참고). + +→ 상세: `base/bind-system-plan.md`의 "Store/State/Source 온톨로지 — +핵심 메커니즘 확정" 절, `base/store-semantics.md`, `base/lifecycle-pattern.md`. ## 확정됨 (2026-08-03 질의응답 라운드, 더 이상 열려있지 않음) - **Store 책임 분리**: base가 `LifetimeHandle` 추상화 + store-bind의 재실행 로직(`process(inst,k,realv)` 재귀)을 소유, provider는 "언제 죽었다고 - 판단할지"(Roblox `Destroying` 등)만 결정. → `research/module-lifecycle-plan.md`, - `research/bind-system-plan.md` + 판단할지"(Roblox `Destroying` 등)만 결정. → `base/module-lifecycle-plan.md`, + `base/bind-system-plan.md` - **Signal 클래스**: 안 만듦 — 콜백 + `Connected` 계산 속성만. → `base/ lifecycle-pattern.md` - **핸들러 계약**: `isHandlable`+`priority`+`process`+`retract` 4종 유지, - tbox식 세분화는 지금 안 함. → `research/bind-system-plan.md` + tbox식 세분화는 지금 안 함. → `base/bind-system-plan.md` - **Ref**: 도입하되 용도는 "id 조회 대체"가 아니라 "외부 관리 instance를 점진적으로 마이그레이션/래핑하기 위한 직접 참조 획득". Tween 등 어떤 핸들러도 대상 획득에 Ref가 필요하지 않음(항상 `inst`를 직접 받음). - → `research/bind-system-plan.md` + → `base/bind-system-plan.md` - **`retract`(구 cleanup) 호출 시점**: 값 교체 시에만 호출, Destroy 시엔 호출 안 함(quad는 자신이 만든 instance의 생명주기 중간에 있지 않으므로 destroy-time 정리 자체가 불필요/불가능). → `base/lifecycle-pattern.md` - **핸들러 내부 상태 저장**: base가 범용 weak-keyed per-instance 저장 유틸 - 제공(모든 핸들러 재사용). → `research/bind-system-plan.md`, + 제공(모든 핸들러 재사용). → `base/bind-system-plan.md`, `base/lifecycle-pattern.md` - **Store 값 설정 문법**: `__newindex`(`myStore.key = v`) 유지, 괄호 생략 커링/`:` 체이닝 인체공학도 유지 — 바뀌는 건 내부 구현(팩토리 함수)뿐. @@ -75,14 +149,14 @@ slot-plan.md, tween-plan.md)를 `AskUserQuestion`으로 하나씩 예/아니오 - **트윈 오버라이드 기본값**: 멈춤(Cancel), 새 트윈은 현재 보간된 값에서 시작. 나머지 세 동작(오버라이드/삭제후재시작/끝점이동후재시작)은 옵션으로 선택 가능. → `research/tween-plan.md` -- **Slot 재마운트 에러**: 즉시 throw. → `research/slot-plan.md` +- **Slot 재마운트 에러**: 즉시 throw. → `base/slot-plan.md` - **`CreatedRef` 콜백 타이밍**: 생성 시점/마운트 시점 둘 다 옵션으로 지원. - → `research/bind-system-plan.md` + → `base/bind-system-plan.md` - **여러 store 값 묶기**: `Store.Combine`류 포지셔널 인자 방식과 Vide식 암묵적 추적 둘 다 기각 — `:With(...)` + `:Compute(fn)`(fn은 with한 값을 포지셔널 인자가 아니라 클로저로 읽음) 방식으로 확정. Unix 파이프에서 영감받은 완전 합성 가능한 State 스트림이 이상향이나 기술적 난이도 미확정 — 과거 시도 - (`quad2-try/quad-core`) 리서치 진행 중. → `research/bind-system-plan.md` + (`quad2-try/quad-core`) 리서치 진행 중. → `base/bind-system-plan.md` ## quad2-try(이전 폐기된 시도) 리서치 완료 — 추가 확정 @@ -95,7 +169,7 @@ slot-plan.md, tween-plan.md)를 `AskUserQuestion`으로 하나씩 예/아니오 간단하다는 판단(위 "최우선 새 열린 질문"의 Store/State/Source 온톨로지 절로 흡수됨). - **`Depend(...)` 액션, `:With` 네이밍**은 이전 시도에서도 지향했던 것과 일치 - — 그대로 채택. → `research/bind-system-plan.md` + — 그대로 채택. → `base/bind-system-plan.md` ## 순수성/이식성, 기존 인스턴스 바인드 — 확인 완료, 낮은 우선순위로 유지 @@ -114,7 +188,7 @@ slot-plan.md, tween-plan.md)를 `AskUserQuestion`으로 하나씩 예/아니오 심각하게 볼지 — 지금은 "별도 네임스페이스 개념은 복잡도 대비 이득이 적다"는 판단으로 보류 중. → `base/architecture.md` 5번 항목. - Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 실제로 필요한 - 상황이 있는지 — 구현 단계에서 실사례로 재검증. → `research/bind-system-plan.md` + 상황이 있는지 — 구현 단계에서 실사례로 재검증. → `base/bind-system-plan.md` --- 전체 순서/우선순위는 루트 `CLAUDE.md`가 최종 소스 — 위 표는 힌트일 뿐 그쪽이 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 7cf60e3..7071a2c 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -32,13 +32,13 @@ ## 정정: Ref 불필요 — 핸들러는 항상 대상 Instance를 직접 받는다 -**이전 초안의 전제가 틀렸음.** Tween 핸들러도 `research/bind-system-plan.md`의 +**이전 초안의 전제가 틀렸음.** Tween 핸들러도 `base/bind-system-plan.md`의 "확정된 디스패치 모델"을 그대로 따르는 store-bind 핸들러 중 하나 — `process(inst, k, v)`가 항상 대상 Instance(`inst`)를 직접 받으므로, 트윈 대상을 얻기 위해 Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 핸들러는 "`k`는 무엇이든, `v`가 Store인 것"을 잡아내는 우선순위 매우 높은 핸들러로 등록되고, `inst`는 이미 파라미터로 주어짐. (Ref 자체는 도입되지만 전혀 다른 -용도 — `research/bind-system-plan.md`의 Ref 절 참고.) +용도 — `base/bind-system-plan.md`의 Ref 절 참고.) ## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 @@ -62,7 +62,7 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 라이브러리가 강제하지 않고, `[Tween(key, tweenData, {onOverride=...})]`처럼 키 설정으로 사용자가 고를 수 있게 열어둠 — `retract(inst, k, v)`가 이전 값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에 -생성한 실제 Tween 객체"는 `research/bind-system-plan.md`가 말하는 base 제공 +생성한 실제 Tween 객체"는 `base/bind-system-plan.md`가 말하는 base 제공 범용 유틸(`inst`를 키로 하는 weak-keyed per-instance 상태 저장소)에 담아두면 됨. diff --git a/CLAUDE.md b/CLAUDE.md index 19cd65a..86676d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,11 +30,25 @@ v2 재작성 시도(`.claude/initreq/quad2-try`)도 리서치 완료 — OOP 상 절충안은 한때 살려볼 후보였으나 **2026-08-04에 사실상 폐기로 재평가**됨(State 자체가 `state(state)`로 분기하는 쪽으로 대체). -**지금 유일하게 결론 안 난 핵심 설계 이슈는 Store/State/Source 온톨로지** -— 검증 라운드 중 "State 프리미티브는 안 만든다"던 기존 결정이 틀렸다는 게 -드러나며 새로 열림. `.claude/research/bind-system-plan.md`의 "Store/State/ -Source 온톨로지" 절, `.claude/question.md`의 "최우선 새 열린 질문" 절 참고 — -아래 "지금 할 일" 1번이 다음 세션이 여기서부터 시작해야 함을 명시. +**Store/State/Source 온톨로지 및 관련 인체공학 질문은 2026-08-04 네 라운드에 +걸쳐 전부 확정됨**(사용자가 공유해준 실제 참고 코드 `.claude/initreq/ +artworks/`, PA님 작성, 로 4차 교차검증까지 마침) — push-invalidate/ +pull-recompute 전파 모델, `:Compute` self/with 인자를 둘 다 lazy State +핸들로 통일, State는 쓰기 불가(값 쓰기는 항상 Store의 `__newindex`), +`Source`는 Store와 별개인 독립 프리미티브로 격상, Slot 생존 확인은 기존 +canExecute 유틸 재사용으로 해소, `store.key` dot-access를 타입 추론 1급 +경로로(인스턴스 생성도 같은 관습, 단 이벤트는 PA님 방식인 평범한 문자열 +키+런타임 리플렉션으로 예외), `RobloxFactory` 재호출 가드(같은 팩토리=무시, +다른 팩토리=에러)까지 확정. 남은 건 정확한 API 표면 이름뿐 — +`.claude/base/bind-system-plan.md` 전체, `.claude/question.md`의 +"2026-08-04" 절들 참고. + +**소스 트리 구조도 확정됨(2026-08-04 5차 라운드)**: `bind-system-plan.md`/ +`module-lifecycle-plan.md`/`slot-plan.md` 모두 `research/`에서 `base/`로 +승격 완료. 모노레포(`quad-base`/`quad-roblox` 서브폴더, RbxUtil 패턴)로 +당장은 모놀리식 진행, 패키지 경계(디스패치 엔진까지 base가 인터페이스로 +소유)까지 확정 — `base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" +절 참고. 아래 "지금 할 일" 1번이 다음 단계(실제 스캐폴딩)를 명시. ## 계획 문서 구조 @@ -80,40 +94,76 @@ Source 온톨로지" 절, `.claude/question.md`의 "최우선 새 열린 질문" ## 지금 할 일 (우선순위순) -1. **[다음 세션 최우선] Store/State/Source 온톨로지 설계.** 2026-08-04 검증 - 라운드 중 "State 프리미티브는 안 만든다"는 이전 결정이 틀렸다는 게 - 밝혀지면서 새로 터져나온 핵심 설계 이슈 — Store=source 집합체, State= - source를 감싸는 조합 가능한 캐시(`state(state)`로 분기), `:Compute` - 캐싱/무효화 전략, Luau 타입 시스템에서 커링 호출의 `state` 추론 문제 - 등이 전부 미정. `.claude/research/bind-system-plan.md`의 "Store/State/ - Source 온톨로지" 절에 지금까지 나온 내용이 정리되어 있음 — 이어서 설계를 - 구체화할 것. `.claude/question.md`의 "최우선 새 열린 질문" 절도 함께 참고. -2. 위 온톨로지가 어느 정도 정리되면 `research/bind-system-plan.md`/ - `research/module-lifecycle-plan.md`를 `base/`로 승격하고, - `base/architecture.md`에 "구현 착수" 섹션을 추가해 실제 소스 트리 구조 - (어느 서브패키지가 뭘 갖는지)를 확정 — 이 시점부터 `qa-request/`/`archive/` - 폴더가 실제로 쓰이기 시작함. -3. 남은 세부 시그니처(`CreatedRef` 정확한 이름, 인스턴스 생성/이벤트 네이밍 - 인체공학, `RobloxFactory`류 팩토리 중복 호출 가드)는 온톨로지 설계와 - 자연스럽게 같이 확정 가능. -4. `research/purity-and-effects-plan.md`(특히 "state 옵저빙 결과로 slot을 - 조작할 때 생존 여부 확인" 열린 질문), `research/existing-instance-bind-plan.md`는 - 급하지 않음 — 스코프 논의만 필요, 구현 착수를 막지 않음. -5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 +1. **[다음 세션 최우선] 실제 스캐폴딩.** 소스 트리 구조는 문서로 이미 확정됨 + (`base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절) — 다음 + 세션에서 실제로 `quad-base/`, `quad-roblox/` 폴더, 각각의 `wally.toml`, + 루트 `default.project.json`, `.luaurc`를 만들 것. 이 시점부터 `qa-request/`/ + `archive/` 폴더가 실제로 쓰이기 시작함. +2. 남은 세부 시그니처(`CreatedRef`/`state()`/`Source()`/`DI`류 정확한 + 이름)는 위 항목과 자연스럽게 같이 확정 가능 — PA님 실 코드(`.claude/ + initreq/artworks/`)를 이미 받아서 교차검증 완료(아래 인수인계 메모 + 참고), `On` 모듈은 이벤트 바인딩 방식이 바뀌며 아예 불필요해짐. +3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 + 필요, 구현 착수를 막지 않음. +4. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). -## 인수인계 메모 (2026-08-04 세션 종료 시점) +## 인수인계 메모 (2026-08-04 세션 종료 시점, 5차 라운드까지 반영) + +**5차 라운드(소스 구조 확정)**: 4차 라운드 종료 시점에 서브에이전트로 먼저 +계획 문서 전체의 정합성을 점검(차질 없음 확인) 후 진행. 패키징 방식은 +서브에이전트 웹 리서치로 확인(`.luaurc` alias 런타임 미지원, wally 심볼릭 +링크/타입 문제, `Sleitnick/RbxUtil`의 모노레포+개별 wally.toml 선례, +`pesde`는 아직 이름) — 모노레포로 당장 진행, 나중에 실제 분리 결정. +패키지 경계는 사용자가 "base=인터페이스, roblox=구현"이라는 원칙을 명확히 +해서 확정 — Store/State/Source 온톨로지뿐 아니라 `process`/`retract` +디스패치 엔진, `LifetimeHandle`/`PerInstanceState` 인터페이스, Ref, Slot +코어 재조정 로직까지 전부 `quad-base`가 소유(다른 엔진에서도 재사용 +가능해야 한다는 전제, 엔진마다 큰 구현 중복 방지가 목적). Slot도 같은 +원칙 적용 확정, 그 과정에서 `k:number,v:Instance` 중첩 인스턴스 자식용 +`InstanceChild` 핸들러가 추가로 필요하다는 게 밝혀짐. `bind-system-plan.md`/ +`module-lifecycle-plan.md`/`slot-plan.md` 세 문서 모두 `research/`에서 +`base/`로 승격 완료, `base/architecture.md`에 전체 소스 트리가 문서화됨 — +실제 폴더/파일 스캐폴딩은 다음 세션(위 "지금 할 일" 1번). + +## 인수인계 메모 (2026-08-04 세션 종료 시점, 4차 라운드까지 반영) 2026-08-03에 확정됐다고 표시된 결정 전체(architecture.md 14개 + lifecycle- pattern/store-semantics/bind-system-plan/module-lifecycle-plan/slot-plan/ tween-plan)를 `AskUserQuestion`으로 하나씩 예/아니오 검증 완료 — 상세는 -`.claude/question.md`의 "2026-08-04 검증 라운드 완료" 절. 대부분 그대로 -확인됐지만, 검증 과정에서 사용자가 실시간으로 설계를 더 전개하면서 **"State -프리미티브는 안 만든다"는 기존 결정이 틀렸다는 게 밝혀짐** — Store/State/ -Source 온톨로지 전체가 이번 세션에서 새로 열린 가장 중요한 설계 스레드로 -떠올랐고, 아직 결론이 안 났음(위 "지금 할 일" 1번). 그 외 자잘한 정정들(Slot -retract=폐기 확정, Pipe COW 후보 폐기 등)은 각 문서에 바로 반영해둠 — 재조사 -불필요. +`.claude/question.md`의 "2026-08-04 검증 라운드 완료" 절. 검증 과정에서 +사용자가 실시간으로 설계를 더 전개하면서 **"State 프리미티브는 안 만든다"는 +기존 결정이 틀렸다는 게 밝혀짐** — Store/State/Source 온톨로지 전체가 이 +세션에서 새로 열린 가장 중요한 설계 스레드로 떠올랐음. + +**같은 날 이어진 2차/3차 라운드에서 그 온톨로지와 인체공학 질문 전부를 +확정함**: push-invalidate/pull-recompute 전파 모델(Fusion식 eager 노드/생성순 +정렬 불필요), `:Compute`의 self/with 인자를 둘 다 lazy State 핸들로 통일 +(별도 `ComputeWithout` 불필요), State는 쓰기 불가(값 쓰기는 Store의 +`__newindex`로만) 확정, `Source`는 Store 내부 디테일이 아니라 값 하나만 +다룰 때 쓰는 독립 공개 프리미티브로 격상, Slot 생존 확인 문제는 새 메커니즘 +없이 기존 canExecute 유틸 재사용으로 해소(부수 효과로 "Store가 Store를 담을 +때 이중 해제 방지 필요한가" 백로그 항목도 "명시적 dispose가 없어 질문 자체가 +성립 안 함"으로 닫힘), `store.key` dot-access를 타입 추론 1급 경로로 삼는 +관습을 인스턴스 생성까지 프로젝트 전역으로 확정, `RobloxFactory` 재호출 +가드(같은 팩토리=무시, 다른 팩토리=에러, `New()`와는 인스턴스별 테이블 +분리로 자연히 공존)까지 확정. + +**4차 라운드에서 사용자가 실제 참고 코드(`.claude/initreq/artworks/`, PA님 +작성 — UI 포함 전반적 설계 패턴을 시범 적용한 데모 모듈)를 공유해줘서 +교차검증**: "DI"는 Dependency Injection이 아니라 Declarative Instance였음 +(정정). 인스턴스 생성은 2트랙 구상보다 단순한 "제네릭 생성자 함수 하나 + +자주 쓰는 클래스만 정적 필드로 미리 바인딩" 모양으로 정정. **이벤트 +바인딩은 `On.EventName` 도트액세스를 접고 PA님 방식(평범한 문자열 키 + +`ReflectionService` 기반 자동 판별)으로 전환** — Store의 dot-access는 실질적 +타입 이득이 있어 그대로 유지, 이벤트만 예외. 전파 모델(push-invalidate/ +pull-recompute)과 라이프사이클(GC-native)은 PA님 코드가 반례처럼 보였으나 +(각각 파생 개념이 없는 단순 pub-sub, 전부 수동 해제) 재검토 후 **기존 +확정 유지** — 라이프사이클은 나중에 하이브리드로 확장 가능한 여지만 기록. +OOP 회피 결정은 PA님의 `class.luau`도 같은 체이닝 상속 문제를 보여 오히려 +보강됨. **더 이상 열려있는 핵심 설계 질문은 없음** — 남은 건 API 표면 이름뿐 +(위 "지금 할 일" 참고). 그 외 자잘한 정정들(Slot retract=폐기 확정, Pipe COW +후보 폐기 등)은 각 문서에 바로 반영해둠 — 재조사 불필요. 이전 세션(2026-08-03) 종료 시점 메모: `.claude/` 전체 스캐폴드 + 대부분의 핵심 아키텍처 결정을 완료, 로컬 git 저장소 초기화+첫 커밋(원격 없음, From c19e82f661284c7e58513750976da4cbb675de2e Mon Sep 17 00:00:00 2001 From: qwreey Date: Tue, 4 Aug 2026 15:49:28 +0900 Subject: [PATCH 04/62] =?UTF-8?q?6=EC=B0=A8=20=EB=9D=BC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=20+=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=ED=99=94/Modifier?= =?UTF-8?q?=20=EB=85=BC=EC=9D=98,=20=EB=AC=B8=EC=84=9C=20=EC=BD=94?= =?UTF-8?q?=ED=8D=BC=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 6차 라운드: 태그 네임스페이싱(Ref로 충분), Store가 Store를 담지 않음 확정 - Modifier 메커니즘 전체 확정(정적 merge, immutable+clone 체이닝, State 필드 지원, "관측해야 실체화된다" 전역 원칙) — base/modifier-plan.md 신설 - 컴포넌트화 논의 시작(research/component-composition-plan.md) — 컴포넌트= 플레인 함수, State/Source 읽기·쓰기 경계, StoreSource 프록시까지 수렴, modifier/Ref의 컴포넌트 경계 통과 방식은 열린 채로 남김 - .claude/ 코퍼스 전체(약 15개 문서)를 서브에이전트로 감사해 여러 라운드에 걸쳐 쌓인 모순/중복/stale 마커/끊긴 참조 다수 수정 - purity-and-effects-plan.md를 research/에서 base/로 승격 - CLAUDE.md: 라운드별 인수인계 메모 3개를 하나로 통합, 오래된 "더 이상 열린 질문 없음" 모순 제거 - question.md: 시간순도 우선순위순도 아니던 구조를 "지금 열려있는 것" 중심 으로 재정리, 용어 정리 제안(State/DI/PerInstanceState 등 우선순위) 추가 Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 14 +- .claude/base/architecture.md | 73 +++-- .claude/base/bind-system-plan.md | 91 ++++--- .claude/base/comparison-fusion-vide.md | 2 +- .claude/base/lifecycle-pattern.md | 24 +- .claude/base/modifier-plan.md | 145 ++++++++++ .claude/base/module-lifecycle-plan.md | 12 +- .../purity-and-effects-plan.md | 7 +- .claude/base/quad-v1-architecture.md | 3 +- .claude/base/slot-plan.md | 16 +- .claude/base/store-semantics.md | 30 ++- .claude/question.md | 249 +++++------------- .../research/component-composition-plan.md | 110 ++++++++ .../research/existing-instance-bind-plan.md | 4 +- .claude/research/tween-plan.md | 10 +- CLAUDE.md | 177 ++++++------- 16 files changed, 585 insertions(+), 382 deletions(-) create mode 100644 .claude/base/modifier-plan.md rename .claude/{research => base}/purity-and-effects-plan.md (90%) create mode 100644 .claude/research/component-composition-plan.md diff --git a/.claude/README.md b/.claude/README.md index 87bc2d9..a7b72cb 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -14,7 +14,7 @@ | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 — 지금은 비어있음 | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | -| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.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/`에만 @@ -26,20 +26,22 @@ |---|---| | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) | | `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선 | -| `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료 | +| `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료(전파 모델 등 일부 서술은 이후 라운드에서 뒤집혔으니 `bind-system-plan.md` 쪽을 최신으로 볼 것) | | `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 패키지 경계까지 확정 | — | +| `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 패키지 경계까지 확정 | +| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정, getter 이름만 남음 | +| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | ## `research/` — 아직 착수 전, 상의 필요 | 문서 | 내용 | 우선순위 | |---|---|---| | `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel | 중 — 세부 옵션만 남음 | -| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | 하 — 문서화 성격, 급하지 않음 | | `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | +| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, `StoreSource` 프록시 — 핵심 골격 수렴, modifier/Ref가 컴포넌트 경계를 어떻게 통과하는지만 남음 | 상 — 사용자가 "가장 문제되는 부분"으로 직접 지목 | ## 참고 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 56f2eb4..61fffbb 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -31,17 +31,36 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 내장되어 store 컴퓨티드 바인드도 가능해야 함. 5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/ `Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유. - 네임스페이싱 문제는 있지만(`.claude/question.md` 참고) 별도 네임스페이스 - 개념을 추가하면 라이브러리 복잡도가 너무 올라간다고 판단 — 당장은 - TagService 그대로 사용. **대신 Ref가 도입됨** — 단 Ref의 용도는 "id로 조회"가 - 아니라 "외부에서 이미 관리되고 있는 instance를 quad로 점진적으로 마이그레이션/ - 래핑하기 위해 직접 참조를 얻는 것"(`base/bind-system-plan.md`의 Ref 절 - 참고) — 둘을 혼동하지 말 것. + 네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리 + 복잡도가 너무 올라간다고 판단 — 당장은 TagService 그대로 사용. **대신 + 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) 시스템 폐기.** Roblox 자체 스타일시트를 쓰는 게 낫다고 판단. - 대신 modifier(spread되는 값, `...`으로 풀리는 것)를 지향 — 함수형 modifier가 - store 바인드를 받을 수도 있음. +7. **Style(Default) 시스템 폐기.** 대신 modifier(spread되는 값, `...`으로 + 풀리는 것)를 지향 — 함수형 modifier가 store 바인드를 받을 수도 있음. + (초기 근거였던 "Roblox 자체 스타일시트를 쓰는 게 낫다"는 6차 라운드에서 + 갱신됨 — 위 5번 항목의 6차 추가분 참고: 스타일시트는 적용 위치 제약과 + 스크립팅 난이도 때문에 오히려 안 쓰기로 하고 modifier kit으로 대체함.) + - **2026-08-04 세션: modifier 메커니즘 전체 확정, 상세는 + `research/modifier-plan.md`로 분리.** 요지만: 런타임 pluggable 핸들러가 + 아니라 디스패치 이전에 정적으로 flatten되는 값(핸들러 레지스트리 미참여, + CSS cascade 문제 회피). Merge 우선순위는 "배열 순서상 나중 modifier가 + 우선"과 "인라인 키는 modifier보다 무조건 우선"이라는 독립된 두 규칙(Lua + 테이블 리터럴이 배열/해시 파트 간 소스 순서를 보존 안 하므로 하나로 합칠 + 수 없음). 값은 immutable — 체이닝 메소드(`:FontSize(...)`류)는 항상 + `table.clone` 후 반환, 원본 mutate 금지(형제 서브트리 오염/재렌더 드리프트 + 방지, 비용은 무시 가능한 수준으로 확인됨). 8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""` 같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로 구현(`base/bind-system-plan.md`). @@ -59,9 +78,10 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 확정으로 재확인 — 더 이상 열린 질문 아님.) 12. **멀티 타겟(pluggable 백엔드) — 특히 GTK 지원까지 염두.** Roblox 전용 렌더 기술(react.lua, Fusion)은 결국 외부 개발자 유인이 없어 발전이 더딜 거라는 - 문제의식. 결과적으로 `plug/roblox`, `plug/base` 정도로 나뉠 전망 — base가 - 가상돔 없이도 프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, - 실제 Roblox 구현은 `quad-roblox` 격 서브패키지가 담당. + 문제의식. 결과적으로 `quad-base`/`quad-roblox`로 나뉨(5차 라운드에서 확정된 + 정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도 + 프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은 + `quad-roblox`가 담당. 13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox 프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()` 추가. @@ -137,21 +157,22 @@ quad/ Tween/purity/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을 막지 않음. -## 아직 미정 (research/로 분리됨) +## Store/State/Source 온톨로지 — 확정됨 (요약) -Tween 플러깅, 순수함수 범위, 이미 생성된 인스턴스에 대한 바인드 — -`.claude/research/` 각 문서 참고, 전체 색인은 `.claude/README.md`. 바인드 -디스패치/Slot/모듈 라이프사이클은 위 "구현 착수" 섹션대로 확정되어 -`.claude/base/`로 승격됨(`bind-system-plan.md`/`module-lifecycle-plan.md`/ -`slot-plan.md`). - -**Store/State/Source 온톨로지(2026-08-04 두 라운드에 걸쳐 확정)**: Store는 -source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 접근할 때마다 -그 source를 감싸는 새 State(자기 고유 value 없는 조합 가능한 캐시)를 +Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 접근할 +때마다 그 source를 감싸는 새 State(자기 고유 value 없는 조합 가능한 캐시)를 반환한다. 전파는 push-invalidate(신호만)/pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림. State는 쓰기 대상이 아니고(값 쓰기는 항상 Store의 `__newindex`), 값 하나만 다룰 땐 -Store와 별개인 가벼운 `Source` 프리미티브를 씀. 남은 건 정확한 API 이름과 -"`store.key` dot-access를 타입 추론 1급 경로로 삼는다"는 제안의 정식 확인 -뿐 — `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절, -`.claude/question.md` 참고. +Store와 별개인 가벼운 `Source` 프리미티브를 씀. `store.key` dot-access를 타입 +추론 1급 경로로 삼는 것도 3차 라운드에서 정식 확정됨 — **더 이상 열린 질문 +아님**, 남은 건 정확한 API 이름뿐. 상세는 `base/bind-system-plan.md`의 +"Store/State/Source 온톨로지" 절 참고. + +## 아직 미정 (research/로 분리됨) + +Tween 플러깅, 이미 생성된 인스턴스에 대한 바인드, 컴포넌트가 modifier/Ref를 +경계 너머로 어떻게 전달하는지 — `.claude/research/` 각 문서 참고, 전체 색인은 +`.claude/README.md`. 바인드 디스패치/Slot/모듈 라이프사이클은 위 "구현 착수" +섹션대로 확정되어 `.claude/base/`로 승격됨(`bind-system-plan.md`/ +`module-lifecycle-plan.md`/`slot-plan.md`). diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 0235c8b..e5358d1 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -122,10 +122,14 @@ Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나 ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로." -이 문서의 제안: "Store 안의 값이 Store"인 경우도 그냥 하나의 (key,value) 쌍일 -뿐이고, 그 값 타입(Store)을 인식하는 핸들러가 pluggable 레지스트리에 등록되어 -있으면 됨 — 위 "재실행 래핑" 방식과 동일한 메커니즘으로 커버됨. 별도 특수 -케이스 코드 불필요. +**2026-08-04 6차 확정: 그런 경우는 없다고 본다.** 위에 적힌 "재실행 래핑으로 +기계적으로는 커버 가능하다"는 제안은 메커니즘상 틀리지 않지만, 실제 설계 +의도와 안 맞음 — Store는 Source에 준하는 존재로 모든 반응형 값의 "시작점" +역할만 함. 시작점은 다른 변화하는 무언가에 연결되는 것을 제공하고자 하지 +않음(= Store가 다른 Store/State를 값으로 담아 자동으로 따라가게 하는 용도로 +쓰지 않음). Store에서 값을 꺼내 State를 옵저빙하다가 콜백으로 다른 Store 값을 +바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의 +보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음. ## Ref — 도입 확정, 단 용도는 재정의됨 @@ -167,29 +171,29 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 **채택 방향**: `:With(...)`로 필요한 의존성을 모으고, 그 뒤 `:Compute(function() ... end)`에서 **`with`한 값을 포지셔널 인자로 받지 않고 클로저로 직접 읽는다** -(정확히 어떤 방식으로 "직접 읽는지"는 아래 열린 질문 — `:fromState` 후보 참고). +(정확히 어떤 방식으로 "직접 읽는지"는 2차 라운드에서 확정 — self/with 값 둘 다 +lazy State 핸들로 통일, 아래 "Store/State/Source 온톨로지" 절의 "`:With`/ +`:Compute`" 부분 참고). -## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 기술적 난이도 미확정 +## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 -**중요한 배경**: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. +**배경**: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. 이상적으로는 store에서 한 값을 추적(track)하면 "State"가 나오고, 거기에 `compute`를 적용하면 또 다른 "State"가 나오는 식 — Unix의 `(cat a; cat b) | while read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최종 목표. `:With`의 두 번째 인자(`b`)도 다른 `:Compute`의 결과물(State)을 그대로 받을 수 있어야 이상적. -**미해결 긴장 관계**: 이걸 구현하는 두 갈래 방식이 있고 어느 쪽이 맞는지 아직 -결정 안 됨: -1. **Compute 체인이 항상 자기 자신을 mutable하게 바꾼다** — 엔지니어링 비용은 - 낮지만, 다른 코드가 나중에 그 체인 뒤에 새 compute를 붙이는(다른 소비자가 - 동일 State에 독립적으로 파생값을 추가하는) 것이 불가능해짐 — 공유/합성이 - 깨짐. -2. **명시적 `State:fromState(state)`류의 비-mutating 생성자** — 합성은 - 안전해지지만 엔지니어링 비용이 더 큼(정확히 얼마나 큰지 미확정). - -이건 `base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 -같은 축의 문제 — 옵션 2가 그 원칙과 더 잘 맞아 보이지만, 실현 가능성 자체가 -아직 검증 안 됨. +**해소됨(2차 라운드) — 두 갈래 방식 중 실질적으로 옵션 2 방향으로 정리됨**: +당시엔 (1) Compute 체인이 자기 자신을 mutable하게 바꾸는 방식(엔지니어링 비용 +낮지만 공유/합성이 깨짐) vs (2) 명시적 `State:fromState(state)`류 비-mutating +생성자(합성은 안전, 비용 미확정) 둘로 긴장이 있었으나, 실제 확정된 모델은 +아래 "Store/State/Source 온톨로지" 절의 **`state(state)`로 기존 state의 +결과를 받아 새 state를 만드는 조합**임 — 매번 새 State를 만든다는 점에서 +옵션 2와 같은 축(비-mutating)이고, 별도 `fromState`/`Pipe` 콤비네이터 타입 +없이도 `state(state)` 하나로 충분하다는 게 최종 결론(`Pipe` 후보는 폐기). +`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 같은 +축의 해법. ## Store/State/Source 온톨로지 — 핵심 메커니즘 확정 (2026-08-04 2차 라운드) @@ -228,6 +232,22 @@ Fusion식 eager 노드·생성순 정렬은 안 만듦** - `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함 ("emit 필요 여부" 열린 질문은 이걸로 해소). +**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** + +위 pull-recompute 규칙을 State 하나의 재계산 메커니즘으로만 읽지 말고, +프로젝트 전역에 적용되는 원칙으로 명시함: **어떤 파생값도 `.value`/`Get()`로 +직접 읽히기(관측) 전까지는 계산되지 않는다.** 이 원칙은 State 자체뿐 아니라, +State를 필드 값으로 담고 있는 다른 구조(예: `base/modifier-plan.md`의 +Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 읽으면 그 +순간이 바로 관측이고, 그 순간 계산이 확정됨. + +**주의 — 구조적 복사는 관측이 아님.** `table.clone`처럼 테이블 레퍼런스만 +복사하는 연산은 안에 담긴 State 핸들을 그대로 옮길 뿐 `.value`/`Get()`을 +호출하지 않으므로 관측이 아니고, 계산을 트리거하지 않음. Modifier 체이닝 +메소드가 `table.clone` 후 필드를 덮어쓰는 것(위 "Immutable 값 + clone 기반 +체이닝")과 이 원칙이 충돌하지 않는 이유가 바로 이것 — clone은 그저 참조 +복사라 State 필드는 클론 이후에도 여전히 살아있는 lazy 핸들로 남음. + **`:With`/`:Compute` — self 인자도 lazy 핸들로 통일** - 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 @@ -278,9 +298,10 @@ Fusion식 eager 노드·생성순 정렬은 안 만듦** 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 리터럴 narrowing 문제 자체가 안 생김. `store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`State`) 폴백으로 격하. -- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성(`DI.Frame`)/이벤트 - (`On.EventName`)까지 관통하는 프로젝트 전역 관습으로 확정**됨 — 아래 - "인스턴스 생성 / 이벤트 네이밍 인체공학" 절 참고. +- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 + 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 + **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). + 아래 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 최신 확정 내용. **`Pipe`(quad2-try 후보)는 폐기 확정** — 별도 `Pipe` 타입에 소유권/버전 가드를 넣어 재설계하는 대신, State 자체가 파이핑 결합체이고 @@ -359,15 +380,16 @@ State/스트림)를 다뤘던 이전 시도가 있었음. 조사 결과 요약: **건질 만한 것 (인체공학/아이디어만, 코드는 아님):** - **`store:Pipe(key):Compute(fn)` 같은 왼쪽에서 오른쪽으로 읽히는 파이프 문법 자체**는 목표로 유지할 가치가 있음. -- **`Pipe`가 mutate-vs-`fromState` 긴장 관계에 제시한 절충안** — "체이닝된 +- **`Pipe`가 mutate-vs-`fromState` 긴장 관계에 제시했던 절충안** — "체이닝된 `Compute`/`Add`/... 호출은 자신이 액션 리스트의 유일한 '끝(tip)'일 때만 공유 배열에 그대로 append(뮤테이션), 이미 다른 코드가 그 지점 이후로 체인을 확장해버렸다면 배열을 복사한 뒤 새 `Pipe` 객체를 반환"하는 **copy-on-write - 방식** — 이건 이 문서의 "mutate-in-place vs `fromState`" 긴장을 실제로 - 풀어보려 한 유일한 시도라 **quad-v2에서 제대로 다시 설계해볼 만한 후보**. - 단, 원본은 "내가 지금 유일한 tip인가" 체크에 소유권/버전 관리가 전혀 없어서 - 경쟁 상황에 취약했고 테스트/실사용 검증도 없었음 — **그대로 베끼지 말고, - 같은 아이디어를 소유권 가드를 제대로 넣어 재설계할 것.** + 방식** — 한때는 이 문서의 "mutate-in-place vs `fromState`" 긴장을 풀어보려 + 한 유일한 시도로서 다시 설계해볼 후보였으나, **아래 "종합"에서 최종적으로 + 폐기됨** — `state(state)` 조합 모델이 소유권/버전 가드 없이도 같은 문제를 + 더 간단히 풀어서 이 절충안 자체가 불필요해짐. 원본이 갖고 있던 진짜 결함 + (소유권/버전 관리 없이 경쟁 상황에 취약, 테스트/실사용 검증도 없었음)은 + 기록으로만 남김. - **`Depend(...)` 액션** — 계산값에는 관여하지 않고 오직 "이 소스가 바뀌면 다시 계산하라"는 추가 의존성만 등록하는 값-투명(value-transparent) no-op 액션. 작지만 깔끔한 아이디어라 이름 그대로 채택할 만함. @@ -472,7 +494,7 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 인덱스라 지금 quad-v2 스코프 밖 — Instance가 아닌 데이터에 태깅이 필요해질 미래 시나리오를 위한 참고 자료로만 기록. - **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지** - (아래 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). + (위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). ## 남은 열린 질문 (`.claude/question.md`에도 취합) @@ -493,9 +515,12 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 검증 대상). **해소된 것**: "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 -필요한가"는 재검토 결과 질문 자체가 성립 안 함으로 결론 — State/Source -그래프 구독이 전부 weak-keyed GC-native(명시적 `dispose()` 호출이 아예 없음, -`base/lifecycle-pattern.md`의 GC 위임 원칙 재사용)라 "같은 걸 두 번 해제"할 -행위 자체가 존재하지 않음(GC는 멱등). "`:Compute`가 with한 값을 어떻게 +필요한가"는 재검토 결과 질문 자체가 성립 안 함으로 결론 — 두 가지 독립적인 +이유로 이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 "Store가 +Store를 저장 가능한가" 절, 2026-08-04 6차 — Store는 Source에 준하는 "시작점" +이라 다른 반응형 값을 담아 자동 연결되는 용도로 안 씀). (2) 설령 발생해도 +State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적 `dispose()` 호출이 +아예 없음, `base/lifecycle-pattern.md`의 GC 위임 원칙 재사용)라 "같은 걸 두 번 +해제"할 행위 자체가 존재하지 않음(GC는 멱등). "`:Compute`가 with한 값을 어떻게 읽는가"/"emit 필요 여부"도 전파 모델 확정으로 해소, `RobloxFactory` 중복 호출/충돌 시나리오·인스턴스 생성/이벤트 네이밍도 위 절에서 전부 확정. diff --git a/.claude/base/comparison-fusion-vide.md b/.claude/base/comparison-fusion-vide.md index 5a90494..bbac9a6 100644 --- a/.claude/base/comparison-fusion-vide.md +++ b/.claude/base/comparison-fusion-vide.md @@ -49,7 +49,7 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 인용되는 원본 비교 자 | 축 | Fusion | Vide | quad-v2 시사점 | |---|---|---|---| -| 전파 모델 | push+pull 하이브리드, eager 집합만 즉시 재계산, 생성순 정렬로 글리치 방지 | 순수 push, 즉시 동기 재평가, 다이아몬드 중복 재평가 미해결(저자 인정) | "Store는 값 자체에 항상 eager 발화, cleanup이 key/value를 먼저 확인" 요구사항은 Vide의 push 모델 + eval-전-cleanup 패턴에 더 가까움. 단 Vide의 naive BFS 대신 Fusion의 생성순 정렬 글리치 방지 규율은 채택할 것. | +| 전파 모델 | 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`). | diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 882c996..962a2ab 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -1,6 +1,6 @@ # 라이프사이클 패턴 — rbvm의 `Connected` + GC 관용구 채택 -**상태**: 결정됨(base) — quad-v2가 채택할 라이프사이클/정리(cleanup) 전략의 원본. +**상태**: 결정됨(base) — quad-v2가 채택할 라이프사이클/정리(`retract`) 전략의 원본. 완료 개념 없음, 구현하면서 세부 조정 있을 수 있음. ## 배경 @@ -42,7 +42,7 @@ rbvm은 실제 Roblox Instance의 파괴를 감지하는 지점을 단 하나로 플래그를 그 콜백에서만 true로 뒤집음. `AncestryChanged`나 폴링 방식은 안 씀. quad-v2도 동일: 인스턴스 라이프사이클 훅 지점은 `Destroying` 하나로 통일. -### 3. 정리(cleanup)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager) +### 3. 정리(`retract`)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager) rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를 저장 — 홀더 객체를 아무도 안 들고 있으면 그 private 레코드도 자동으로 사라짐. @@ -51,13 +51,17 @@ rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 전체가 통째로 죽을 때의 순서 있는 dispose 훅. **quad-v2 원칙: 기본은 GC 위임, 즉시 정리는 "안 끊으면 죽은 참조를 순회하게 되는 작은 포인터"류에만 국한.** -### 4. Signal 자체는 커스텀 구현체를 그대로 재사용 가능 +### 4. (참고 기록) rbvm의 Signal 자체는 재사용 가능한 범용 emitter였음 — 실제로는 채택 안 함 `signal.luau`의 `Signal`/`Connection` 클래스는 rbvm 프록시 시스템에 의존하지 않는 범용 이벤트 emitter임 (`Connect`/`Once`/`Wait`/`Fire`/`Destroy`, -`IsInited`/`OnInit`/`OnUninit` 지연 활성화 훅 포함). **단, 사용자 원 메모에는 -"시그널 자체 구현은 아닌듯... 콜백 정도로도 충분"이라고 되어 있어 서로 상충함** -— 아래 열린 질문 참고. +`IsInited`/`OnInit`/`OnUninit` 지연 활성화 훅 포함). 사용자 원 메모에는 +"시그널 자체 구현은 아닌듯... 콜백 정도로도 충분"이라는 언급이 있어 한때 +이 문서 초안 단계에서 상충하는 것처럼 보였으나, **이 질문은 2026-08-04 +검증 라운드에서 최종 확정으로 재확인됨 — 더 이상 열린 질문 아님** +(`base/architecture.md` 11번 항목도 동일하게 명시). 결론은 아래 "확정: Signal +클래스는 안 만든다" 절 참고 — 커스텀 `Signal`/`Connection` 클래스는 만들지 +않고, 콜백 + `Connected` 계산 속성만 채택한다. ### 5. rbvm에서 그대로 가져오면 안 되는 것 (버그 발견됨) @@ -97,8 +101,9 @@ Destroy되면 그 대상에 묶인 것들(Tween 등)도 자연히 죽은 상태 이 원칙 때문에 "값 교체 시 이전 처리를 무르는 것"(아래 `retract`)과 "완전 소멸 시 정리"는 **하나로 통일** — 후자는 애초에 안 만듦. `research/ -tween-plan.md`/`base/slot-plan.md`의 "cleanup" 표기는 전부 `retract`로 -갱신됨(이름 변경 근거는 아래). +tween-plan.md`/`base/slot-plan.md`의 "cleanup" 표기는 대부분 `retract`로 +갱신됨(이름 변경 근거는 아래) — 잔여 표기 확인은 진행 중, 해당 문서들은 +각자 별도로 정리될 예정. ## 함수 안에서 만든 옵저버도 GC 대상이 되어야 함 — 범용 "생명 바인드 유틸" 필요 @@ -180,4 +185,5 @@ Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 쉬움) — 실제 의미는 "이전에 적용한 처리를 무른다/멈춘다"이므로 **`retract`** 로 통일. (`revert`, `rescind`도 검토했으나 `retract`가 "이전에 취한 조치를 철회한다"는 의미로 가장 정확 — `process`/`retract` 쌍으로 자연스럽게 대구를 -이룸.) 모든 문서에서 이 이름으로 갱신. +이룸.) 대부분의 문서에서 이 이름으로 갱신됨 — 잔여 "cleanup" 표기가 남은 +문서가 있을 수 있으며, 그 확인/정리는 진행 중. diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md new file mode 100644 index 0000000..c1273bf --- /dev/null +++ b/.claude/base/modifier-plan.md @@ -0,0 +1,145 @@ +# Modifier 설계 (정적 merge, immutable 체이닝) + +**상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable +값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. 남은 +건 getter 정확한 이름뿐(구현 단계). Modifier가 컴포넌트 경계를 어떻게 +통과하는지(다중 루트, 상속 방식)는 별개 문제로 +`research/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번) 원칙과 충돌함. + +### 2. Merge 우선순위: 배열 순서와 인라인은 독립된 두 규칙 + +`Frame { modifier1, modifier2, Name = ... }` 평탄화 시: +(a) 배열에 나열된 modifier들끼리는 순서상 나중 것이 우선. +(b) 명시적 키(인라인)는 modifier가 뭘 하든 무조건 우선. +Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스트 순서를 보존하지 +않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두 +규칙으로 쪼개야 함. + +### 3. Immutable 값 + clone 기반 체이닝 + +컴포지션 트리를 타고 내려가며 조금씩 변형되는 modifier(문서 뷰어에서 상위 +TextStyle을 상속해 타이틀만 1.2배 키우는 경우 — Jetpack Compose의 +`TextStyle.merge()`/`CompositionLocal`과 동일한 use case)는 특히 위험함 — +mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 서브트리가 +오염되거나(한쪽이 mutate하면 다른 쪽도 영향받음), 재렌더 시 값이 누적 +드리프트하는 버그가 생김(`.claude/question.md` 초기 논의의 "원본 테이블 +덮어쓰기/루프 깨짐" 우려와 동일 클래스). + +**해결**: 모든 변환 메소드(`:FontSize(...)`류 체이닝)는 내부에서 +`table.clone(self)`로 새 테이블을 만든 뒤 필드만 덮어써 반환 — 원본은 +절대 mutate하지 않음. 별도의 제네릭 clone 콤비네이터 타입 +(`modifier<>(modifier):Set` 류 아이디어)은 기각 — 그런 타입을 만들면 +`base/architecture.md` 3번의 "복사 구현 지양, 필요한 곳만 팩토리 함수로 +명시적 복사" 원칙을 다시 재작업하는 셈이라, 각 변환 메소드 자체가 그 원칙을 +따라 알아서 최소한만 복사하면 충분. + +**성능**: Luau `table.clone`은 native shallow-copy라 modifier 크기(보통 +한 자리~여남은 개 필드) 기준 비용 무시 가능, 렌더/컴포지션 타임에만 +발생(프레임마다 도는 게 아님). State가 이미 `:With`/`:Compute`마다 새 +노드를 할당하는 것과 같은 급의 비용이라 일관되고, mutable+문서화 경고보다 +오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서 +낫다고 판단 — **immutable 기본으로 확정**. + +### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음 + +`:FontSize(value)`(리터럴) / `:FontSize(function(current) return +current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 콜백, +여러 줄로 풀어쓰고 싶을 때는 현재 값을 getter로 꺼내 계산 후 리터럴로 +다시 넣는 스타일 둘 다 인체공학상 필요하다고 판단. + +변환 함수는 State의 `:Compute`처럼 lazy State 핸들을 넘길 필요가 없음(*필드가 +순수 데이터인 일반적인 경우에 한해* — 필드가 State일 때의 예외는 아래 참고). +계산 비용 자체가 없는 순수 데이터라면 콜백엔 그냥 raw 현재 값을 즉시 넘기면 +충분(State의 self-lazy-핸들 문제와는 다른 카테고리). + +**내부 구현**: `__real` 같은 별도 래퍼는 불필요해 보임 — 데이터를 테이블에 +직접 두고 메소드는 공유 메타테이블 `__index`로 붙이면, `table.clone`이 +메타테이블까지 그대로 복사해주는 Luau 동작 덕분에 클론해도 체이닝이 안 +끊김. flatten도 그 테이블 필드를 직접 읽으면 됨. + +**Getter 정확한 모양은 미정** — `mod:Get("FontSize")` 같은 전용 메소드로 +할지, 아니면 Store/DI 관습처럼 dot-access(`mod.fontSize`) 자체가 읽기 +경로를 겸하게 해서 별도 `:Get()`이 아예 불필요하게 할지는 구현 단계에서 +확정. **다만 getter의 동작 자체은 확정**: 필드가 State면 getter 호출이 +곧 관측이라 그 순간 계산되어 확정된(더 이상 반응하지 않는) 값이 반환됨 — +`base/bind-system-plan.md`의 "관측해야 실체화된다" 전역 원칙 그대로 적용 +(아래 참고). + +### 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일 때 반응성을 보존하고, 리터럴 셋터(혹은 +getter로 꺼내 계산 후 리터럴로 다시 넣는 멀티라인 스타일)는 그 순간 값을 +확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는 것으로 +문서화. + +### 4-2. Modifier는 소유권/유일성 제약이 없음 + +Modifier는 자식(child)을 담지 않음 — 마운트 정체성이 없는 순수 값. 그래서 +어떤 컴포넌트가 특정 modifier를 실제로 적용하든 안 하든, 또 같은 modifier를 +트리 여러 곳에 반복 적용하든 에러가 나지 않고 상관없음(Ref나 Slot 자식처럼 +"정확히 한 곳에만 마운트돼야 한다"는 소유권 제약이 이들에게는 있지만 +Modifier에는 없음). + +### 5. 타입 출처는 이미 확정된 dot-access 관습 재사용 + +"누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에 +이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트 +전역 관습(`base/bind-system-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 +됨 — `Modifier.Rounded(8)`/`Modifier.FontSize(...)`처럼 DI 쪽 "제네릭 +생성자 함수 하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. +(주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 +PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/bind-system-plan.md` +"이벤트 바인딩 정정" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 +생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.) + +### 6. State/Pipe 쪽엔 영향 없음 — 이미 있던 결정의 재확인일 뿐 + +Modifier가 immutable해야 하는 이유(변환마다 clone)와 State가 이미 +"`:With`/`:Compute`마다 새 노드를 만든다"(`base/bind-system-plan.md` 2차 +라운드 확정)로 확정해둔 이유는 같은 클래스의 문제(공유 mutable 상태로 인한 +오염 방지)임을 이번 논의에서 재확인했을 뿐 — State/Source 온톨로지 자체엔 +변경 사항 없음. 파이프 분기(`:With(...):Compute(fn)`)는 이미 코드에 +명시적으로 쓰는 구조라 "암묵적 분기"가 애초에 존재하지 않음 — 새로 결정할 +것 없음. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- Getter 정확한 이름/모양(`:Get(key)` vs dot-access 겸용) — 후순위, 구현 + 단계에서 다른 세부 API 이름들과 같이 확정 가능. +- Modifier가 컴포넌트 경계를 어떻게 통과하는지(다중 루트, 상속 방식)는 + `research/component-composition-plan.md`에서 계속 다룸 — 이 문서가 다루는 + "값 자체의 동작"과는 별개 문제. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 927b691..4ed563e 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -53,9 +53,15 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 - **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은 이름 붙은 체이닝 연산(named modifier)은 명시적으로 안 만들기로 확정, 대신 일반 함수를 받는 형태로 통일(`base/store-semantics.md` 참고). "너무 verbose한 - 연산들은 오히려 일관성을 해친다"는 게 이유. + 연산들은 오히려 일관성을 해친다"는 게 이유. (주의: 아래의 v2 `:With(...)`는 + 이름만 같을 뿐 여기서 안 만들기로 한 v1의 `:With`와는 다른 연산임 — v1은 + "함수/테이블에서 값을 가져오는" 가공 연산이었고, v2는 그냥 "여러 State를 + 의존성으로 모으는" 수집 연산.) - **여러 store 값을 묶어 유연하게 처리하는 방법**(`useEffect`류 dependency - array)은 있으면 좋겠다는 요청 — API 시그니처는 미정, `base/bind-system-plan.md`의 남은 열린 질문 참고. + array)은 있으면 좋겠다는 요청이었고 — **API 시그니처도 확정됨**: + `:With(...)`로 의존성을 모으고 `:Compute(fn)`으로 파생 State를 만드는 + 형태, 상세는 `base/store-semantics.md`의 "여러 스토어 값을 묶어 처리하는 + 것" 절 참고. - `can execute store bind` 후킹 자체는 `Connected` 계산 속성으로 대체된다는 잠정 제안이 그대로 유지되고, 여기에 더해 **완전 소멸(Destroy) 시점엔 아무 처리도 필요 없다**는 원칙까지 확정됨(`base/lifecycle-pattern.md`) — 즉 이 @@ -93,6 +99,6 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — - 바로 아래 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 + 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 스코핑됨, 별도 재설계 불필요. diff --git a/.claude/research/purity-and-effects-plan.md b/.claude/base/purity-and-effects-plan.md similarity index 90% rename from .claude/research/purity-and-effects-plan.md rename to .claude/base/purity-and-effects-plan.md index b8d4e5d..0653768 100644 --- a/.claude/research/purity-and-effects-plan.md +++ b/.claude/base/purity-and-effects-plan.md @@ -1,8 +1,9 @@ # 컴포넌트 순수성이 아니라 "이식성" 문제 (재정의됨) -**상태**: research — 사용자 확인 완료로 문제 자체는 명확해짐, 남은 건 문서화 -강도 정도. 원본: `.claude/initreq/raw-userinput.md` "순수함수에 대한 범위를 -정할 필요가 있음" / "진짜 부작용은 외부에 만들어버린다" 절. +**상태**: base — 확정됨(2026-08-04 세션에 `research/`에서 승격). 남은 건 +가이드 문서 내 배치 위치 정도로 기술적 결정 사항은 없음. 원본: +`.claude/initreq/raw-userinput.md` "순수함수에 대한 범위를 정할 필요가 있음" / +"진짜 부작용은 외부에 만들어버린다" 절. ## 정정: "순수함수 여부"가 아니라 "이식성(portability)" 문제였다 diff --git a/.claude/base/quad-v1-architecture.md b/.claude/base/quad-v1-architecture.md index 322e1c1..bd14cb8 100644 --- a/.claude/base/quad-v1-architecture.md +++ b/.claude/base/quad-v1-architecture.md @@ -24,7 +24,8 @@ Mount(ScreenGui, Frame {...}) `Class.Extend()`로 재사용 컴포넌트(`Init/Render/AfterRender/Getter/Setter/ UpdateTriggers/Unload`) 정의 가능. `Store.GetObject(id)`류 id 기반 전역 조회는 -v2에서 태그 시스템으로 대체 예정(`base/store-and-tags.md` 참고). +v2에서 대체될 예정 — Ref 도입과 네임스페이싱 판단까지 포함해 최신 상세는 +`base/architecture.md` 5번 항목 참고. ## 핵심 내부 동작 요약 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index ec266e6..8a143b7 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -65,14 +65,24 @@ Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 편하다는 방향. **기울어진 결론**: 별도 "Named Slot" 개념 없이, store나 파라미터로 넘기고 그게 그냥 ref처럼 바인드되는 모양. +**상태 확인(2026-08-04 문서 정리 시점)**: 이 방향은 아래 "열린 질문" 절이나 +`.claude/question.md`의 확정 목록 어디에도 명시적으로 흡수된 흔적이 없음 — +아직 정식 확정 절차(`AskUserQuestion` 등)를 거치지 않은 것으로 보임. **열린 +질문으로 유지**, `.claude/question.md`에도 반영 필요. + ## Slot과 Store 바인드의 관계 (`retract` 순서) Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`(구 cleanup, `base/lifecycle-pattern.md` 참고) 핸들러가 필요함 — 한번 넘어간 slot 요소가 나중에 `retract`되면 삭제되는지, 아니면 "부모의 소유이니 부모가 처리"해야 -하는지 검토 필요. **기울어진 결론**: 부모가 정리 정도만 미리 수행하고 다시 -`process`하면 되므로, 부모에게 위임(자식 slot 요소 자체가 스스로 정리를 -실행하는 게 아니라). +하는지 검토 필요. **기울어진 결론(잠정안, 이후 정정됨)**: 부모가 정리 정도만 +미리 수행하고 다시 `process`하면 되므로, 부모에게 위임(자식 slot 요소 자체가 +스스로 정리를 실행하는 게 아니라). + +> **정정(2026-08-04 검증 라운드)**: 위 "부모 위임" 잠정안은 이후 **폐기** +> 쪽으로 정정됨 — 아래 "확정" 절과 `.claude/question.md`("Slot의 `retract` +> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은 +> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님. 이건 `base/bind-system-plan.md`의 "Store 바인드는 재실행 래핑" 확정 모델과 맞물림 — slot이 store 값으로 오면, store 바인드 핸들러가 이전 slot diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 63b3b42..c48e758 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -1,7 +1,8 @@ # Store 의미론 — 부작용 허용, State는 Store 위의 조합 가능한 캐시 레이어 -**상태**: base — 부작용 허용/Store 문법 부분은 확정. State/Source 온톨로지는 -2026-08-04 검증 라운드에서 새로 열린 진행 중인 설계 스레드(`base/bind-system-plan.md` 참고). 원본: `.claude/initreq/raw-userinput.md` +**상태**: base — 전부 확정. State/Source 온톨로지는 2026-08-04 검증 +라운드에서 새로 열려 같은 세션 2~4차 라운드에 걸쳐 확정까지 마침 — 최신 +상세는 `base/bind-system-plan.md` 참고. 원본: `.claude/initreq/raw-userinput.md` "store는 부작용을 허용함" / "state는 어떻게 구현하는가" 절. ## Store는 부작용을 허용하는 게 기본 디자인 @@ -12,7 +13,7 @@ 다만 한 가지는 명확히 구분: **렌더 리턴 위에서 무언가를 observe하는 것은 그냥 부작용**이다 (`useEffect`와 유사한 것으로 문서화). 이건 "허용되는 부작용"이 아니라 -"당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`research/ +"당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`base/ purity-and-effects-plan.md`와 연결됨). **보강(2026-08-04 검증 라운드): 부작용은 심각도가 다른 두 갈래로 나뉜다.** @@ -23,7 +24,7 @@ purity-and-effects-plan.md`와 연결됨). 2. **경계를 넘는 부작용** — globalStore처럼 컴포넌트 바깥의 전역 상태를 다루는 경우. 게임 UI 특성상(스킬/주변 환경에 영향받는 UI 등) 완전히 막을 수는 없지만, 라이브러리로 재사용하려는 컴포넌트가 이런 부작용을 - 가지면 이식성이 떨어짐(`research/purity-and-effects-plan.md`와 연결). + 가지면 이식성이 떨어짐(`base/purity-and-effects-plan.md`와 연결). **해소됨(2026-08-04 2차 라운드)**: state를 옵저빙해서 나온 결과로 slot에 `clear`/`add` 같은 연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면 @@ -60,14 +61,14 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 방향으로 좁혀짐 — 별도 `Pipe` 타입을 만들어 소유권/버전 가드를 넣는 대신 State 자체가 "파이핑 결합체"이고 `state(state)`로 분기하면 될 걸로 보임 (`Pipe` 후보는 사실상 폐기 쪽으로 기움). 상세는 `base/bind-system-plan.md`의 - "Store/State/Source 온톨로지" 절 참고 — **아직 완전히 결론난 설계는 아니고, - 구현 단계에서 더 다뤄야 할 진행 중인 스레드.** + "Store/State/Source 온톨로지" 절 참고 — **이 절 이후 2~4차 라운드에 걸쳐 + 전부 확정됨, 더 이상 진행 중인 스레드 아님.** -미해결로 남은 것: `:Compute`의 캐싱/무효화 전략(값이 바뀌었는데 듣는 소비자가 -없으면 연산을 미루는 dirty-flag 방식 등), Luau 타입 시스템에서 `store "key"` -같은 커링 호출이 오버로드 함수 타입으로 `state`를 정확히 추론하기 어려운 -문제(문자열 리터럴이 as-const로 좁혀지지 않는 문제) — 둘 다 열린 채로 -`base/bind-system-plan.md`에서 계속 다룰 것. +과거 "미해결로 남은 것"으로 적었던 두 항목도 모두 해소됨: `:Compute`의 +캐싱/무효화 전략은 push-invalidate(신호만)/pull-recompute(`Get()` 시점)로 +확정(`base/bind-system-plan.md` "전파 모델" 절), `store "key"` 커링의 타입 +추론 문제는 `store.key`(dot-access)를 1급 경로로 확정하며 해소(같은 문서 +"타입 추론 문제" 절, 3차 라운드). ## Store 값 설정 문법 — v1 인체공학 유지 (확정) @@ -92,8 +93,11 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 `useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 요구가 있었음(v1의 `myStore "a,b"` 콤마-조인 문자열 방식은 폐기 대상 — `base/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). **v1의 -`:Add`/`:With`/`:Tween` 같은 이름 붙은(named) 체이닝 연산은 만들지 않음** — -대신 일반 함수를 받아 처리. 최종 형태는 `:With(...)`로 의존성을 모으고 +`:Add`/`:With`/`:Tween`처럼 값을 직접 가공하는 이름 붙은(named) 체이닝 연산은 +만들지 않음** — 대신 일반 함수를 받아 처리. (주의: 아래의 v2 `:With(...)`는 +이름만 같을 뿐 v1의 `:With`와는 다른 연산임 — v1은 "함수/테이블에서 값을 +가져오는" 가공 연산이었고, v2는 그냥 "여러 State를 의존성으로 모으는" 수집 +연산.) 최종 형태는 `:With(...)`로 의존성을 모으고 `:Compute(fn)`으로 파생 State를 만드는 것으로 확정 — `Store.Combine({a,b}, fn)`류 포지셔널 인자 방식은 기각됨. 정확한 lazy 인자 규칙(self/with 값 둘 다 State 핸들로 넘기고 `.value`를 실제로 읽을 때만 계산)은 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. diff --git a/.claude/question.md b/.claude/question.md index bd2dee3..1cd4770 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -1,194 +1,89 @@ -# 확인/결정 필요 목록 (전체 취합) +# 확인/결정 필요 목록 -각 plan 문서에 흩어진 "사용자 확인 필요" 절의 취합본. **막고 있는 항목은 -거의 없음** — 대부분 합리적 기본값/방향을 잡아두고 research 단계에 머물러 -있음. 사용자가 Lua/Roblox 엔진에 대해 깊이 아는 사람이라는 전제로, 우선순위 -높은 것부터 정렬. +**2026-08-04 세션 말미에 전체 재정리함.** 예전엔 라운드(1차~6차)별로 문서가 +계속 쌓이면서 순서가 시간순도 우선순위순도 아니게 됐고, 이미 해소된 라운드 +기록이 새로 열린 질문보다 위에 있는 등 혼동을 유발했음(문서 감사에서 발견). +그 상세 히스토리는 지우지 않았음 — git log로 이 파일의 이전 버전을 보거나, +각 `base/`/`research/` 문서 안의 라운드 표시("2026-08-04 3차 라운드" 등)를 +따라가면 그대로 남아있음. 이 문서는 이제 **"지금 열려있는 것" 우선으로만** +구성. -## 2026-08-04 5차 라운드 완료 — 소스 트리 구조 확정 +## 지금 열려있는 것 (우선순위순) -`.claude/base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고. -`base/bind-system-plan.md`/`base/module-lifecycle-plan.md`/`base/slot-plan.md`가 -이 라운드에서 `research/`에서 승격됨. +### 1. [최우선] 컴포넌트 경계에서 modifier/Ref가 어떻게 전달되는가 -- **패키징**: 최종 목표는 다중 wally 패키지지만, 지금 Luau 툴링(wally 타입 - 단절, `luau-lsp` 심볼릭 링크 해석 문제)이 불안정해서 당장은 모놀리식 — - `Sleitnick/RbxUtil` 패턴(루트 통합 개발/테스트, 서브폴더마다 자체 - `wally.toml`) 채택. `.luaurc` alias는 런타임 require에서 아직 엔진 미지원 — - 편집기 경험용으로만 사용, 런타임 require는 상대경로. -- **패키지 경계**: `quad-base` = Store/State/Source 온톨로지+전파 **+** - pluggable 디스패치 엔진(`process`/`retract`, 핸들러 계약, `LifetimeHandle`/ - `PerInstanceState` 인터페이스, Ref, Slot 코어 재조정 로직) — 전부 - "인터페이스"로, 다른 엔진(GTK 등)에서도 재사용 가능해야 한다는 전제. - `quad-roblox` = 위 인터페이스의 실제 구현체(`RobloxFactory`, Property/Event/ - Attribute/Tag/Tween/Slot 적용 핸들러, `DI` 인스턴스 생성자) — 이유: 엔진마다 - 큰 구현을 중복하지 않기 위함(rbvm의 relation 통합 시도와 같은 동기). -- **Slot 패키지 경계**: 재조정 로직(add/remove/clear)은 base, 실제 Instance - `Parent`/`Destroy` 조작은 roblox의 핸들러가 담당 — `base/slot-plan.md` - "base/roblox 패키지 경계" 절. -- **새 핸들러 필요성 확인**: `k:number, v:Instance`(중첩 인스턴스를 직접 - 자식으로 넣는 경우, `Frame { Frame {} }`)를 위한 `InstanceChild` 핸들러가 - Slot과 별개로 필요 — `quad-roblox/src/Handlers/InstanceChild.luau`. +사용자가 "지금 quad에서 가장 문제되는 부분"으로 직접 지목. 컴포넌트가 +플레인 함수이고 반환하는 루트가 여러 개(혹은 Slot으로 갈라지는 구조)일 때, +호출부가 넘긴 modifier/Ref가 "어느 루트로 가야 하는지" 모호해지는 케이스가 +있음. Jetpack Compose는 언어 강제가 아니라 "컴포저블은 `modifier` 파라미터를 +받아 루트에 적용해야 한다"는 순수 관례(+린트)로 풂 — quad도 비슷한 관례 +기반으로 갈 수 있어 보이나 다중 루트 케이스는 미정. -## 2026-08-04 검증 라운드 완료 +**주의**: modifier "값 자체"가 어떻게 동작하는지(정적 merge, immutable+clone +체이닝, State 필드 지원)는 이미 완전히 확정됨(`base/modifier-plan.md`) — 이 +질문은 그것과 별개로 "경계를 어떻게 통과하느냐"만 다룸, 혼동하지 말 것. -아래 "확정됨" 절 전체(architecture.md 14개 항목, lifecycle-pattern.md, -store-semantics.md, bind-system-plan.md, module-lifecycle-plan.md, -slot-plan.md, tween-plan.md)를 `AskUserQuestion`으로 하나씩 예/아니오 재검증 -완료 — 대부분 그대로 확인됐지만, 아래는 검증 과정에서 실제로 문서가 수정된 -항목: +→ 상세/배경: `research/component-composition-plan.md`. -- **`State` 프리미티브는 "안 만든다"가 아니라 실제로 필요함** — 정정 완료, - `base/store-semantics.md` 참고. **이 결과로 Store/State/Source 온톨로지 - 전체가 새로운 열린 설계 스레드로 떠올랐음** — 아래 "최우선 새 열린 질문" - 참고. -- Slot의 `retract` 동작이 "부모 위임" 잠정안에서 "폐기(옮기지 않음)"로 확정 - — `base/slot-plan.md`. -- quad2-try의 `Pipe` copy-on-write 후보는 사실상 폐기, `state(state)` 조합 - 모델로 대체 — `base/bind-system-plan.md`. -- `Connected` 체크/GC 위임/`Destroying` 훅 관련 뉘앙스 보강(엔진별 인터페이스 - 주입, quad는 rbvm보다 즉시정리 필요성이 낮음) — `base/lifecycle-pattern.md`. -- base 유틸(per-instance 저장소, 생명 바인드)은 인터페이스만, 실제 구현은 - `RobloxFactory(BaseModule)`류 백엔드 팩토리가 주입 — `base/bind-system-plan.md`. +### 2. 용어 정리 (사용자 요청, 진행 중) -## 최우선 새 열린 질문 (검증 라운드에서 새로 터져나옴) +사용자 원 메모: "quad는 register라던가 좀 부정확하거나 느낌이 바로 와닿지 +않던 용어들이 많음 — 전체적 용어를 보고 생각해볼래? 제안을 줘, 나도 같이 +볼게." 1차 제안 완료, 아래는 우선순위순 요약 — 최종 판단은 사용자와 계속 +논의 필요: -**전부 확정됨** — 아래 "2026-08-04 3차 라운드" 절 참고. 이 섹션에 새 항목이 -생기면 여기 추가. +- **`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`의 "인스턴스 생성" 절 참고). +- **`PerInstanceState`(2순위)**: 핵심 프리미티브 `State`와 이름이 겹쳐서 + 실제로는 완전히 무관한 유틸(인스턴스별 weak-keyed 저장소)인데 혼동 + 유발 가능 — `PerInstanceStorage`/`InstanceData` 등 대안. +- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 + 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 + 헷갈릴 수 있음. +- **`CreatedRef`/`canExecute`(3순위, 사소함)**: `CreatedRef`는 과거분사형이라 + 생성자처럼 안 읽힘. `canExecute`는 실제로 "이 핸들이 아직 살아있나" + 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. +- **이미 지나간 사례로 참고**: `register`(v1) → `State`(v2) 리네임은 + "모호함"은 풀었지만 "다른 뜻으로 이미 쓰이는 단어"라는 새 문제를 만든 + 셈 — 이번 정리에서 같은 패턴을 조심할 것. +- `Store`/`Source`/`Modifier`/`Ref`/`process`/`retract`/`isHandlable`은 + 업계 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. -## 2026-08-04 4차 라운드 완료 — PA님 실 코드(`initreq/artworks`) 교차검증 +### 3. 낮은 우선순위 -사용자가 실제 참고 코드를 공유(`.claude/initreq/artworks/`, PA님 작성) — -아래 두 항목이 3차 라운드 잠정안에서 정정됨, 나머지는 재검토 후 기존 확정 -유지: +- `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 + 착수를 막지 않음. +- **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — + `base/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 + 가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록. -- **"DI" = Declarative Instance**(Dependency Injection 아님) — 3차 라운드의 - 오해 정정. -- **이벤트 바인딩 정정**: `On.EventName` 도트액세스 안 씀 — PA님 방식(평범한 - 문자열 키 + `ReflectionService` 기반 자동 판별, `Frame { MouseButton1Click - = fn }`)으로 전환. Store의 `store.key`는 실질적 타입 이득이 있어 dot-access - 유지, 이벤트만 예외. -- **인스턴스 생성**: 2트랙(`DI.Frame`/`DI.New<>`) 대신 PA님 코드처럼 - 제네릭 생성자 함수 하나 + 자주 쓰는 클래스만 정적 필드로 미리 바인딩하는 - 더 단순한 모양으로 정정. -- **전파 모델(push-invalidate/pull-recompute)·라이프사이클(GC-native)은 - 재검토 후 기존 확정 유지** — PA님 코드가 반례처럼 보였으나(전자는 push-값 - 단순 pub-sub, 후자는 전부 수동 해제) 대등한 비교가 아니었거나(파생/합성 - 개념 자체가 없음) 지금 필요성이 없다는 게 사용자 판단. 라이프사이클은 - 나중에 하이브리드로 확장 가능한 여지만 기록. -- OOP 회피 결정은 PA님의 `class.luau`도 같은 체이닝 상속 보일러플레이트를 - 보여 오히려 보강됨. Instance 태그는 CollectionService 직접 사용 유지. +## 참고: 지금까지 확정된 것 (요약) -→ 상세: `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 -인체공학" 절, "Store/State/Source 온톨로지"의 "PA님 코드와의 교차검증" 절, -`base/lifecycle-pattern.md`의 "교차검증" 절. +전부 `base/`에 문서화되어 더 이상 열려있지 않음 — 상세 근거/논의 과정이 +필요하면 아래 문서를 열어볼 것(라운드별 세부 히스토리는 각 문서 안에 +"2026-08-04 O차 라운드" 식으로 표시돼 있음): -## 2026-08-04 3차 라운드 완료 — dot-access 관습 확정, RobloxFactory 가드 확정 (일부 4차 라운드에서 정정됨) - -- **dot-access를 프로젝트 전역 관습으로 확정**: "정적으로 알려진 것=필드 - 접근, 동적인 것=문자열 호출 폴백"이 Store(`store.key`/`store "key"`)와 - 인스턴스 생성에 적용됨 — **이벤트는 4차 라운드에서 예외로 정정**(위 참고). -- **`RobloxFactory` 재호출 가드 확정**: 같은 팩토리로 재호출 시 무시 - (no-op, hot-reload 안전), 다른 팩토리로 재호출 시 에러(유일 슬롯 충돌). - `New()`와는 인스턴스별 테이블 분리로 자연히 공존 — 재설계 불필요. (4차 - 라운드에서 변경 없음) - -→ 상세: `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 -인체공학" 절, "base 유틸은 인터페이스..." 절의 재호출 가드 부분. - -## 2026-08-04 2차 라운드 완료 — Store/State/Source 온톨로지 핵심 메커니즘 - -위 최우선 질문 중 "Store/State/Source 온톨로지 전체"와 "부작용이 slot 생존 -여부와 어떻게 연관되는가"는 `AskUserQuestion`으로 확인 완료, 더 이상 열려있지 -않음: - -- **전파 모델**: push-invalidate(신호만, 값 안 실음) / pull-recompute(`Get()` - 시점에만 재계산) — Fusion식 eager 노드·생성순 정렬은 안 만듦. -- **`:Compute`의 self 인자**: raw 값이 아니라 State 핸들 자체를 넘겨서 self도 - with한 값과 동일하게 lazy하게(`.value`를 실제로 읽을 때만 계산) 처리 — - 별도 `ComputeWithout` 불필요. -- **State는 쓰기 대상이 아님**: `.value`는 읽기 전용, 값 쓰기는 항상 Store의 - `__newindex`로만. `Source`는 Store 내부 디테일이 아니라 값 하나만 다룰 때 - 쓰는 별도의 가벼운 공개 프리미티브로 격상. -- **Slot 생존 확인**: 별도 메커니즘 없이 기존 "생명 바인드 유틸"의 - `canExecute`로 통일 게이트. -- **`store.key` dot-access 타입 추론 제안**: 3차 라운드에서 정식 확정됨(위 - "2026-08-04 3차 라운드 완료" 절 참고). - -→ 상세: `base/bind-system-plan.md`의 "Store/State/Source 온톨로지 — -핵심 메커니즘 확정" 절, `base/store-semantics.md`, `base/lifecycle-pattern.md`. - -## 확정됨 (2026-08-03 질의응답 라운드, 더 이상 열려있지 않음) - -- **Store 책임 분리**: base가 `LifetimeHandle` 추상화 + store-bind의 재실행 - 로직(`process(inst,k,realv)` 재귀)을 소유, provider는 "언제 죽었다고 - 판단할지"(Roblox `Destroying` 등)만 결정. → `base/module-lifecycle-plan.md`, - `base/bind-system-plan.md` -- **Signal 클래스**: 안 만듦 — 콜백 + `Connected` 계산 속성만. → `base/ - lifecycle-pattern.md` -- **핸들러 계약**: `isHandlable`+`priority`+`process`+`retract` 4종 유지, - tbox식 세분화는 지금 안 함. → `base/bind-system-plan.md` -- **Ref**: 도입하되 용도는 "id 조회 대체"가 아니라 "외부 관리 instance를 - 점진적으로 마이그레이션/래핑하기 위한 직접 참조 획득". Tween 등 어떤 - 핸들러도 대상 획득에 Ref가 필요하지 않음(항상 `inst`를 직접 받음). - → `base/bind-system-plan.md` -- **`retract`(구 cleanup) 호출 시점**: 값 교체 시에만 호출, Destroy 시엔 - 호출 안 함(quad는 자신이 만든 instance의 생명주기 중간에 있지 않으므로 - destroy-time 정리 자체가 불필요/불가능). → `base/lifecycle-pattern.md` -- **핸들러 내부 상태 저장**: base가 범용 weak-keyed per-instance 저장 유틸 - 제공(모든 핸들러 재사용). → `base/bind-system-plan.md`, - `base/lifecycle-pattern.md` -- **Store 값 설정 문법**: `__newindex`(`myStore.key = v`) 유지, 괄호 생략 - 커링/`:` 체이닝 인체공학도 유지 — 바뀌는 건 내부 구현(팩토리 함수)뿐. - → `base/store-semantics.md` -- **Store의 named modifier(`:Add`/`:Mul` 등)**: 안 만듦 — 일반 함수를 받는 - 형태로 통일. → `base/store-semantics.md` - -## 추가 확정됨 (2번째 라운드) - -- **트윈 오버라이드 기본값**: 멈춤(Cancel), 새 트윈은 현재 보간된 값에서 시작. - 나머지 세 동작(오버라이드/삭제후재시작/끝점이동후재시작)은 옵션으로 선택 - 가능. → `research/tween-plan.md` -- **Slot 재마운트 에러**: 즉시 throw. → `base/slot-plan.md` -- **`CreatedRef` 콜백 타이밍**: 생성 시점/마운트 시점 둘 다 옵션으로 지원. - → `base/bind-system-plan.md` -- **여러 store 값 묶기**: `Store.Combine`류 포지셔널 인자 방식과 Vide식 암묵적 - 추적 둘 다 기각 — `:With(...)` + `:Compute(fn)`(fn은 with한 값을 포지셔널 - 인자가 아니라 클로저로 읽음) 방식으로 확정. Unix 파이프에서 영감받은 완전 - 합성 가능한 State 스트림이 이상향이나 기술적 난이도 미확정 — 과거 시도 - (`quad2-try/quad-core`) 리서치 진행 중. → `base/bind-system-plan.md` - -## quad2-try(이전 폐기된 시도) 리서치 완료 — 추가 확정 - -- **OOP 상속/`--&` 커스텀 파서/Slot 스텁은 확인대로 죽은 접근** — 절대 반복 - 금지, Slot은 from-scratch 설계 그대로 진행(재조사 불필요). -- **mutate-vs-`fromState` 긴장 관계**: quad2-try의 `Pipe` copy-on-write - 절충안(유일한 tip일 때만 뮤테이션, 아니면 복사)이 한때 유력 후보였으나 - **2026-08-04 검증 라운드에서 사실상 폐기로 재평가됨** — 별도 `Pipe` 타입 - 대신 State 자체가 파이핑 결합체이고 `state(state)`로 분기하는 쪽이 더 - 간단하다는 판단(위 "최우선 새 열린 질문"의 Store/State/Source 온톨로지 - 절로 흡수됨). -- **`Depend(...)` 액션, `:With` 네이밍**은 이전 시도에서도 지향했던 것과 일치 - — 그대로 채택. → `base/bind-system-plan.md` - -## 순수성/이식성, 기존 인스턴스 바인드 — 확인 완료, 낮은 우선순위로 유지 - -- **"순수함수" 문제는 실제로는 "이식성" 문제였음** — 재사용 의도 컴포넌트가 - 전역 store를 직접 참조하면 이식성이 깨짐(단일 페이지용 컴포넌트나 라이브러리 - 내부 전용 공유 상태는 문제 없음). 기술적 강제 안 함, 문서 경고 수준으로 - 확정. → `research/purity-and-effects-plan.md` -- **이미 생성된 인스턴스 재바인드**: 실제 요청한 사용자를 본 적 없지만 - `retract` 인프라가 이미 있어 미래에 자연스럽게 가능해질 여지가 있음 — - "미지원" 확정도, 착수도 안 함, 진짜 열린 가능성으로만 유지. → `research/ - existing-instance-bind-plan.md` - -## 급하지 않음, 여유 있을 때만 - -- 태그 시스템의 네임스페이싱 부재(라이브러리 간 충돌 가능성)를 얼마나 - 심각하게 볼지 — 지금은 "별도 네임스페이스 개념은 복잡도 대비 이득이 적다"는 - 판단으로 보류 중. → `base/architecture.md` 5번 항목. -- Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 실제로 필요한 - 상황이 있는지 — 구현 단계에서 실사례로 재검증. → `base/bind-system-plan.md` +| 주제 | 문서 | +|---|---| +| 전체 아키텍처 결정(디스패치 모델, 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 필드 지원) | `base/modifier-plan.md` | +| 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | +| Fusion/Vide 비교 리서치(주의: 일부 서술은 이후 라운드에서 뒤집힘, 문서 내 정정 표시 참고) | `base/comparison-fusion-vide.md` | +| v1 내부 동작 스냅샷 | `base/quad-v1-architecture.md` | +| 트윈 오버라이드(기본값 Cancel), 세부 옵션만 남음 | `research/tween-plan.md` | +| quad2-try(폐기된 이전 시도) 리서치 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW 전부 죽은 접근으로 확인, 반복 조사 금지 | `base/bind-system-plan.md` | --- 전체 순서/우선순위는 루트 `CLAUDE.md`가 최종 소스 — 위 표는 힌트일 뿐 그쪽이 diff --git a/.claude/research/component-composition-plan.md b/.claude/research/component-composition-plan.md new file mode 100644 index 0000000..77f4185 --- /dev/null +++ b/.claude/research/component-composition-plan.md @@ -0,0 +1,110 @@ +# 컴포넌트화 (Roblox 기본 오브젝트 이외의 사용자 정의 컴포넌트) + +**상태**: research — 2026-08-04 세션 채팅 논의에서 핵심 골격 수렴, 세부 +API 이름/modifier·Ref passthrough는 미정. 사용자가 "지금 quad에서 가장 +문제되는 부분"으로 직접 지목한 주제. `base/bind-system-plan.md`의 +Store/State/Source 온톨로지가 먼저 확정된 뒤에야 이 논의가 열림 — 그 +문서가 선행 컨텍스트. + +## 문제 + +v1의 `Class.Extend()`(Init/Render/AfterRender/Getter/Setter/UpdateTriggers, +`base/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)는 이번 논의에서 다루는 Source 양방향 프록시가 대체. + +## 수렴된 결론 + +### 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(독립이든 Store 소속 `StoreSource` 프록시든)는 파생이 아니라 +항상 원본 슬롯 하나를 직접 가리키므로 쓰기가 의미 있음 — **사용자 확정** +("맞음. 확실해"). + +### 3. `StoreSource`: Source를 인터페이스+구현체로 두고, Store 키에서 그 인터페이스를 구현하는 얇은 프록시를 받음 + +- **Source = 인터페이스이자 구현체**: 독립 생성자 `Source(initial)`가 기본 + 구현체, `store:GetSource("key")`(가칭)류 접근자가 반환하는 값은 같은 + 인터페이스를 구현하는 별도의 얇은 프록시(`StoreSource`) — 읽기는 + `store.key`로, 쓰기는 `store.key = v`로 위임. **내부 Source 객체를 그대로 + 노출하지 않음** — 그러면 "쓰기는 오직 Store의 `__newindex`뿐"이라는 기존 + 확정과 새 쓰기 경로가 충돌하게 됨. +- **캐시 안 함**: State가 이미 "매번 새로 만듦, store에 캐시 안 됨"으로 + 확정돼 있어 일관성 + 엔지니어링 비용 둘 다 이쪽이 쌈 — **사용자 확정** + ("그냥 엔지니어링적으로 비용이 싼거 택해"). + +### 4. Source 직접 전달(양방향)은 핸들러 계약 확장 없이 타입 유니온으로 처리 — 단, 실사용 범위는 좁음 + +- 핸들러가 값을 받을 때 `Source | State` 유니온으로 받고, 내부에서 + 타입 체크만 하면 됨(Source면 인스턴스 변경 이벤트에 걸어 역방향 쓰기까지 + 처리, State면 읽기만) — `isHandlable`/`priority`/`process`/`retract` 4종 + 계약에 5번째 항목을 추가할 필요 없음. Source 자체가 계산이 없는 원천이라 + 가능한 단순화 — **사용자 확정**("그냥 타입 상 source를 받거나 state를 + 받거나 하면 됨. source 자체는 원천이라 컴퓨팅 같은거 없어"). +- **하지만 실사용은 좁을 것으로 예상**: `isEnabled`처럼 여러 조건에 영향 + 받는(=파생된) 값은 애초에 State지 Source가 아니므로 이 경로로 못 넘김. + 즉 Source 직접 전달이 통하는 건 진짜 단순한 1:1 원본-토글 케이스뿐이고, + 일반적인 경우엔 React식 `value(State) + onChange(callback)` 패턴이 기본 + — **사용자 확정**("isenabled가 여러 조건에 영향 받으면 바로 문제가 + 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 + 일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함"). + +### 5. 리프(Roblox 프로퍼티) 바인딩엔 원칙적으로 State만 + +계산된 최종값만 실제로 인스턴스에 반영되어야 하므로, 리프 바인딩은 State가 +일반 경로. Source는 리프 바인딩용 프리미티브가 아니라, 아주 단순한 구조에서 +콜백 보일러플레이트를 줄이기 위한 좁은 용도의 예외 — **사용자 확정** +("리프 바인딩엔 state만 쓰이지 않을까... source는 그냥 아주 단순한 +구조에서 콜백을 넣고 하는 복잡함을 줄이기 위함일 뿐임"). + +## 아직 열린 질문 (`.claude/question.md`에도 취합) + +- **modifier/Ref가 컴포넌트 경계를 어떻게 통과하는가**: 컴포넌트가 플레인 + 함수이고 반환하는 루트가 여러 개(혹은 Slot으로 갈라지는 구조)일 때, 호출부가 + 넘긴 modifier/Ref가 "어느 루트로 가야 하는지" 모호해지는 케이스가 있음. + Jetpack Compose는 언어 강제가 아니라 "컴포저블은 `modifier` 파라미터를 + 받아 루트에 적용해야 한다"는 순수 관례(+린트)로 풂 — quad도 비슷한 관례 + 기반으로 갈 수 있어 보이나 다중 루트 케이스는 미정. **주의: 이건 "경계를 + 어떻게 통과하는가"의 문제이고, "modifier 값 자체가 어떻게 동작하는가"(정적 + merge, immutable 체이닝)는 `research/modifier-plan.md`로 이미 별도 확정됨 + — 둘을 혼동하지 말 것.** +- **정확한 API 이름**: `Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 + 자체도 불확실 — 아마 불필요), `GetSource` 계열 접근자 이름, `Source` + 독립 생성자 이름은 전부 가칭. `base/bind-system-plan.md`의 "남은 열린 + 질문" 절(정확한 함수/생성자 이름 미정)과 같은 급의 후순위 항목. +- **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와 + 무관. diff --git a/.claude/research/existing-instance-bind-plan.md b/.claude/research/existing-instance-bind-plan.md index c4ad3f7..d0b45f8 100644 --- a/.claude/research/existing-instance-bind-plan.md +++ b/.claude/research/existing-instance-bind-plan.md @@ -6,12 +6,12 @@ ## 문제 이미 생성된 Roblox Instance에 새로운 `{k=v}` 프롭 테이블을 나중에 바인드하는 -걸 허용할지. 허용하려면 이전 바인드를 끊는 처리가 필요한데, cleanup이 +걸 허용할지. 허용하려면 이전 바인드를 끊는 처리가 필요한데, retract가 구현되어 있어도 바로 지원하는 건 엔지니어링 비용이 높음. ## 기울어진 방향 -**UB로 두거나, 마일스톤(추후 구현)으로 미룬다.** cleanup이 이미 있고 store +**UB로 두거나, 마일스톤(추후 구현)으로 미룬다.** retract가 이미 있고 store 바인드도 우선순위 높은 플러그라면 이론적으로는 가능해 보이지만(핸들러 레지스트리가 이미 "우선순위 스캔 후 bind" 구조라 재바인드도 같은 경로를 타면 됨), 초기 구현에서 **우선순위를 낮게** 잡아야 함 — 문제 유무가 많을 수 있어서. diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 7071a2c..591a512 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -1,7 +1,9 @@ -# Tween / 애니메이션 플러깅 (착수 전, 사용자와 상의 필요) +# Tween / 애니메이션 플러깅 (기본값 확정, 옵션 키 이름만 남음) -**상태**: research — 방향은 뚜렷하게 잡혀 있으나(라이브러리가 트윈을 직접 -구현하지 않는다) cleanup 순서/오버라이드 시맨틱은 미확정. 원본: +**상태**: research — 방향은 뚜렷하게 잡혀 있고(라이브러리가 트윈을 직접 +구현하지 않는다), `retract` 순서/오버라이드 기본값(Cancel)도 확정됨. 남은 건 +기본값 외 나머지 오버라이드 동작을 고르는 옵션 키의 정확한 이름/시그니처 +정도. 원본: `.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은 항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이 반응 그래프 안에 있는 설계는 명시적 반면교사 — `base/comparison-fusion-vide.md` @@ -79,7 +81,7 @@ Destroy 시점엔 아무 것도 안 함(라이프타임 `Connected` 체크로 ## 네임스페이스드 객체 (성능상 이유로 보류) 트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 — -TagService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페이스가 따로 있을 +CollectionService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페이스가 따로 있을 필요가 있는지는 미정(단, 위 정정으로 이 절 자체의 필요성이 낮아짐 — 핸들러가 이미 대상을 직접 받으므로 "나중에 이름으로 찾아서 트윈"할 필요 자체가 잘 없을 수 있음). diff --git a/CLAUDE.md b/CLAUDE.md index 86676d9..9c38224 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,48 +20,33 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 길게 잡음. **지금은 설계/계획 단계이고 구현은 아직 시작 전** — 저장소 루트에 실제 소스 -코드(`src/` 등)가 없음. 2026-08-03에 확정됐던 핵심 아키텍처 결정들(Store -책임 분리, `process`/`retract` 디스패치 모델, Signal 미채택, Ref 역할, Store -문법 인체공학, 트윈 기본 오버라이드, Slot 재마운트 에러 처리, 순수성→이식성 -재정의 등)은 2026-08-04에 `AskUserQuestion`으로 하나씩 재검증까지 마쳐서 -확정 상태 — `.claude/question.md`의 "확정됨" 절 참고. 이전에 시도했다 폐기한 -v2 재작성 시도(`.claude/initreq/quad2-try`)도 리서치 완료 — OOP 상속/커스텀 -파서/Slot 스텁은 확인된 죽은 접근이라 반복 금지, `Pipe`의 copy-on-write -절충안은 한때 살려볼 후보였으나 **2026-08-04에 사실상 폐기로 재평가**됨(State -자체가 `state(state)`로 분기하는 쪽으로 대체). +코드(`src/` 등)가 없음. 핵심 아키텍처(Store 책임 분리, `process`/`retract` +디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘, +컴포넌트=플레인 함수)는 전부 `.claude/base/`에 문서로 확정돼 있음 — 먼저 +`.claude/base/architecture.md`를 읽을 것. **단, "핵심 설계 질문이 더 이상 +없다"는 뜻은 아님** — 컴포넌트화(특히 modifier/Ref가 컴포넌트 경계를 어떻게 +통과하는지)는 사용자가 직접 "지금 quad에서 가장 문제되는 부분"으로 지목한 +채 아직 열려있음, 아래 "지금 할 일" 참고. -**Store/State/Source 온톨로지 및 관련 인체공학 질문은 2026-08-04 네 라운드에 -걸쳐 전부 확정됨**(사용자가 공유해준 실제 참고 코드 `.claude/initreq/ -artworks/`, PA님 작성, 로 4차 교차검증까지 마침) — push-invalidate/ -pull-recompute 전파 모델, `:Compute` self/with 인자를 둘 다 lazy State -핸들로 통일, State는 쓰기 불가(값 쓰기는 항상 Store의 `__newindex`), -`Source`는 Store와 별개인 독립 프리미티브로 격상, Slot 생존 확인은 기존 -canExecute 유틸 재사용으로 해소, `store.key` dot-access를 타입 추론 1급 -경로로(인스턴스 생성도 같은 관습, 단 이벤트는 PA님 방식인 평범한 문자열 -키+런타임 리플렉션으로 예외), `RobloxFactory` 재호출 가드(같은 팩토리=무시, -다른 팩토리=에러)까지 확정. 남은 건 정확한 API 표면 이름뿐 — -`.claude/base/bind-system-plan.md` 전체, `.claude/question.md`의 -"2026-08-04" 절들 참고. - -**소스 트리 구조도 확정됨(2026-08-04 5차 라운드)**: `bind-system-plan.md`/ -`module-lifecycle-plan.md`/`slot-plan.md` 모두 `research/`에서 `base/`로 -승격 완료. 모노레포(`quad-base`/`quad-roblox` 서브폴더, RbxUtil 패턴)로 -당장은 모놀리식 진행, 패키지 경계(디스패치 엔진까지 base가 인터페이스로 -소유)까지 확정 — `base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" -절 참고. 아래 "지금 할 일" 1번이 다음 단계(실제 스캐폴딩)를 명시. +이전에 시도했다 폐기한 v2 재작성 시도(`.claude/initreq/quad2-try`)도 리서치 +완료 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` copy-on-write 절충안은 확인된 +죽은 접근이라 반복 조사 금지(`base/bind-system-plan.md` 참고). ## 계획 문서 구조 `.claude/README.md`가 색인. 요약: - `.claude/base/` — 확정된 아키텍처/컨텍스트, plan/done 개념 없음. 먼저 `.claude/base/architecture.md`를 읽을 것. -- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. +- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 지금은 + `tween-plan.md`(세부 옵션만 남음), `existing-instance-bind-plan.md`(급하지 + 않음), `component-composition-plan.md`(**사용자가 최우선으로 지목한 열린 + 주제**) 세 개뿐. - `.claude/qa-request/`, `.claude/archive/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, 지금은 비어있음. - `.claude/initreq/` — 클론해둔 참고 레포(quad v1, Fusion, Vide, rbvm, tbox, - code-docker) + 원본 요청. **읽기 전용, `.gitignore`로 커밋 제외됨** — 내용을 - 다른 곳으로 옮기지 말고 항상 원본 그대로 둘 것. 리서치가 더 필요하면 이 - 폴더를 다시 파고들 것. + code-docker) + PA님 실 코드(`artworks/`) + 원본 요청. **읽기 전용, + `.gitignore`로 커밋 제외됨** — 내용을 다른 곳으로 옮기지 말고 항상 원본 + 그대로 둘 것. 리서치가 더 필요하면 이 폴더를 다시 파고들 것. - `.claude/question.md` — 사용자가 답해야 할 질문 전체 취합(우선순위순). - 루트 `HUMAN_TODO.md` — 사람만 할 수 있는 일(로컬 GUI 조작, 스케줄/루프 설정 등). @@ -72,7 +57,9 @@ canExecute 유틸 재사용으로 해소, `store.key` dot-access를 타입 추 컨텍스트 보호. 이미 완료된 v1/rbvm/tbox/Fusion/Vide 리서치 결과는 `.claude/base/`에 정리되어 있으니 중복 조사하지 말고 먼저 그걸 볼 것. - **병렬화 가능한 작업은 Agent 여러 개를 한 메시지에 동시 호출.** 서로 독립적인 - 파일/주제를 다루는 리서치나 구현 조사가 여기 해당. + 파일/주제를 다루는 리서치나 구현 조사, 또는 서로 다른 문서 파일을 고치는 + 문서 정리 작업이 여기 해당(단, 같은 파일을 동시에 고치는 에이전트를 병렬로 + 띄우지 말 것 — 충돌함). - **크리티컬한 설계 결정은 구현으로 밀어붙이지 말고 plan을 research/에 남긴 채 연기.** 사용자는 Lua/Roblox 엔진을 깊이 아는 사람 — 근거와 선택지를 문서에 정리해두면 사용자가 깨어있을 때 훑어보고 답해줄 것. `.claude/question.md`에 @@ -81,6 +68,12 @@ canExecute 유틸 재사용으로 해소, `store.key` dot-access를 타입 추 조사하게 되는 재작업을 막기 위함. `.claude/base/`로 승격, `.claude/qa-request/`로 이동, 또는 문서 자체를 갱신. code-docker/webmanager의 `.claude/` 관리 방식이 좋은 예시(`.claude/initreq/code-docker/webmanager/.claude/README.md` 참고). +- **문서가 쌓이면서 모순/중복/stale 마커가 생기기 쉬움 — 주기적으로 감사할 + 것.** 2026-08-04 세션에 실제로 전체 `.claude/` 코퍼스에서 이런 문제가 + 다수 발견되어 정리함(아래 "최근 세션 요약" 참고) — 여러 라운드에 걸쳐 + 같은 문서를 계속 고치다 보면 "정정됨" 표시가 원래 문장에 안 반영되고 + 방치되는 패턴이 반복되니, 큰 방향 전환이 있을 때마다 관련 문서 전체를 + 훑어 확인할 것. - **Roblox Studio MCP 연결 시 주의**: Studio는 잘 죽는 편 — 죽었을 때 살리려고 위험한 명령을 반복 시도하지 말 것. 그런 상황이면 MCP 없이 할 수 있는 작업만 하거나 대기. 연결 방법은 `HUMAN_TODO.md` 1번 항목 참고(사용자가 Studio에서 @@ -94,77 +87,59 @@ canExecute 유틸 재사용으로 해소, `store.key` dot-access를 타입 추 ## 지금 할 일 (우선순위순) -1. **[다음 세션 최우선] 실제 스캐폴딩.** 소스 트리 구조는 문서로 이미 확정됨 - (`base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절) — 다음 - 세션에서 실제로 `quad-base/`, `quad-roblox/` 폴더, 각각의 `wally.toml`, - 루트 `default.project.json`, `.luaurc`를 만들 것. 이 시점부터 `qa-request/`/ - `archive/` 폴더가 실제로 쓰이기 시작함. -2. 남은 세부 시그니처(`CreatedRef`/`state()`/`Source()`/`DI`류 정확한 - 이름)는 위 항목과 자연스럽게 같이 확정 가능 — PA님 실 코드(`.claude/ - initreq/artworks/`)를 이미 받아서 교차검증 완료(아래 인수인계 메모 - 참고), `On` 모듈은 이벤트 바인딩 방식이 바뀌며 아예 불필요해짐. -3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 +1. **컴포넌트화 논의 계속 — 사용자가 직접 "가장 문제되는 부분"으로 지목.** + `research/component-composition-plan.md` 참고. 핵심 골격(컴포넌트=플레인 + 함수, State/Source 읽기·쓰기 경계, `StoreSource` 프록시)과 Modifier + 메커니즘 자체(`base/modifier-plan.md`, 완전 확정)는 수렴됨 — 남은 건 + **modifier/Ref가 컴포넌트 경계(특히 다중 루트)를 어떻게 통과하는가**라는 + 진짜 설계 질문(이름 문제가 아님). 다음 세션에서 이걸 이어서 파고들 것. +2. **실제 스캐폴딩.** 소스 트리 구조는 문서로 이미 확정됨(`base/ + architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절) — `quad-base/`, + `quad-roblox/` 폴더, 각각의 `wally.toml`, 루트 `default.project.json`, + `.luaurc`를 만들 것. 1번의 컴포넌트 경계 논의가 `DI`/`Modifier` 모듈 + 설계에 영향을 주므로, 그 결론이 안 나온 상태에서도 나머지 구조(Store/ + State/Source, 디스패치 엔진, Slot)는 그대로 스캐폴딩 가능 — 막을 필요 + 없음(`architecture.md`에도 명시). 이 시점부터 `qa-request/`/`archive/` + 폴더가 실제로 쓰이기 시작함. +3. **용어 정리 — 사용자가 별도로 요청, 진행 중.** "register"(v1) 같이 + 부정확한 이름들을 전체적으로 재검토하자는 요청 — 1차 제안 완료(우선순위 + 순: `State`가 React/Vue식 "쓸 수 있는 로컬 상태"라는 통상 의미와 반대라 + 가장 위험, `DI`가 Dependency Injection 축약어와 충돌, `PerInstanceState`가 + 핵심 프리미티브 `State`와 이름 충돌 — 세부는 `.claude/question.md` 참고), + 사용자와 같이 계속 논의 필요. +4. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 필요, 구현 착수를 막지 않음. -4. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 +5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). -## 인수인계 메모 (2026-08-04 세션 종료 시점, 5차 라운드까지 반영) +## 최근 세션 요약 (2026-08-04, 6차 라운드 이후) -**5차 라운드(소스 구조 확정)**: 4차 라운드 종료 시점에 서브에이전트로 먼저 -계획 문서 전체의 정합성을 점검(차질 없음 확인) 후 진행. 패키징 방식은 -서브에이전트 웹 리서치로 확인(`.luaurc` alias 런타임 미지원, wally 심볼릭 -링크/타입 문제, `Sleitnick/RbxUtil`의 모노레포+개별 wally.toml 선례, -`pesde`는 아직 이름) — 모노레포로 당장 진행, 나중에 실제 분리 결정. -패키지 경계는 사용자가 "base=인터페이스, roblox=구현"이라는 원칙을 명확히 -해서 확정 — Store/State/Source 온톨로지뿐 아니라 `process`/`retract` -디스패치 엔진, `LifetimeHandle`/`PerInstanceState` 인터페이스, Ref, Slot -코어 재조정 로직까지 전부 `quad-base`가 소유(다른 엔진에서도 재사용 -가능해야 한다는 전제, 엔진마다 큰 구현 중복 방지가 목적). Slot도 같은 -원칙 적용 확정, 그 과정에서 `k:number,v:Instance` 중첩 인스턴스 자식용 -`InstanceChild` 핸들러가 추가로 필요하다는 게 밝혀짐. `bind-system-plan.md`/ -`module-lifecycle-plan.md`/`slot-plan.md` 세 문서 모두 `research/`에서 -`base/`로 승격 완료, `base/architecture.md`에 전체 소스 트리가 문서화됨 — -실제 폴더/파일 스캐폴딩은 다음 세션(위 "지금 할 일" 1번). +**6차 라운드**: 남아있던 "급하지 않음" 질문 두 개 해소 — 태그 네임스페이싱 +충돌은 컴포넌트 단위로는 Ref가 대신 해결해줘서 심각하게 안 봄(`architecture.md` +5번), Store가 Store를 담는 경우는 없음으로 확정(Store는 Source에 준하는 +"시작점"이라 다른 반응형 값에 자동 연결되지 않음, `bind-system-plan.md`). -## 인수인계 메모 (2026-08-04 세션 종료 시점, 4차 라운드까지 반영) +**그 이후 채팅에서 세 가지 큰 스레드가 새로 열림/정리됨**: +- **Modifier 메커니즘 전체 확정** — 런타임 pluggable 핸들러가 아니라 정적 + merge, immutable+`table.clone` 기반 체이닝, 필드가 State일 수도 있는 + 경우의 setter/getter 동작까지 전부 확정(`base/modifier-plan.md`, 새로 + base 승격). 이 논의에서 "관측해야 실체화된다"는 프로젝트 전역 원칙도 + 명문화(`bind-system-plan.md`). +- **컴포넌트화 논의 시작, 아직 미완** — v1의 `Class.Extend()` 자동-store + 매직은 폐기하고 React식으로 값을 명시적으로 전달하는 방향으로 수렴, + `StoreSource`(Source를 인터페이스+구현체로 보고 Store 키에서 얇은 + 프록시로 얻는 것) 아이디어까지 나왔지만 modifier/Ref의 컴포넌트 경계 + 통과 방식은 미해결(`research/component-composition-plan.md`, 위 "지금 + 할 일" 1번). +- **문서 전체 감사 및 정리** — `.claude/` 코퍼스 전체(약 15개 문서)를 + 서브에이전트로 감사해 여러 라운드에 걸쳐 쌓인 모순/중복/stale 마커를 + 대거 발견하고 수정(예: 이벤트 dot-access 확정 여부가 문서 내에서 서로 + 모순, 이미 해소된 질문이 "미해결"로 방치, 존재하지 않는 문서/섹션을 + 가리키는 끊긴 참조 다수, `TagService`/`CollectionService` 혼용 등). + `research/purity-and-effects-plan.md`도 내용이 이미 확정 상태라 `base/`로 + 승격. **이 CLAUDE.md 자체도 이번에 오래된 라운드별 인수인계 메모 3개를 + 이 요약 하나로 통합하며 정리함** — 라운드별 상세 히스토리가 필요하면 + git log와 각 `base/`/`research/` 문서 안의 라운드 표시(예: "2026-08-04 + 3차 라운드")를 참고할 것, 여기서 전부 반복하지 않음. -2026-08-03에 확정됐다고 표시된 결정 전체(architecture.md 14개 + lifecycle- -pattern/store-semantics/bind-system-plan/module-lifecycle-plan/slot-plan/ -tween-plan)를 `AskUserQuestion`으로 하나씩 예/아니오 검증 완료 — 상세는 -`.claude/question.md`의 "2026-08-04 검증 라운드 완료" 절. 검증 과정에서 -사용자가 실시간으로 설계를 더 전개하면서 **"State 프리미티브는 안 만든다"는 -기존 결정이 틀렸다는 게 밝혀짐** — Store/State/Source 온톨로지 전체가 이 -세션에서 새로 열린 가장 중요한 설계 스레드로 떠올랐음. - -**같은 날 이어진 2차/3차 라운드에서 그 온톨로지와 인체공학 질문 전부를 -확정함**: push-invalidate/pull-recompute 전파 모델(Fusion식 eager 노드/생성순 -정렬 불필요), `:Compute`의 self/with 인자를 둘 다 lazy State 핸들로 통일 -(별도 `ComputeWithout` 불필요), State는 쓰기 불가(값 쓰기는 Store의 -`__newindex`로만) 확정, `Source`는 Store 내부 디테일이 아니라 값 하나만 -다룰 때 쓰는 독립 공개 프리미티브로 격상, Slot 생존 확인 문제는 새 메커니즘 -없이 기존 canExecute 유틸 재사용으로 해소(부수 효과로 "Store가 Store를 담을 -때 이중 해제 방지 필요한가" 백로그 항목도 "명시적 dispose가 없어 질문 자체가 -성립 안 함"으로 닫힘), `store.key` dot-access를 타입 추론 1급 경로로 삼는 -관습을 인스턴스 생성까지 프로젝트 전역으로 확정, `RobloxFactory` 재호출 -가드(같은 팩토리=무시, 다른 팩토리=에러, `New()`와는 인스턴스별 테이블 -분리로 자연히 공존)까지 확정. - -**4차 라운드에서 사용자가 실제 참고 코드(`.claude/initreq/artworks/`, PA님 -작성 — UI 포함 전반적 설계 패턴을 시범 적용한 데모 모듈)를 공유해줘서 -교차검증**: "DI"는 Dependency Injection이 아니라 Declarative Instance였음 -(정정). 인스턴스 생성은 2트랙 구상보다 단순한 "제네릭 생성자 함수 하나 + -자주 쓰는 클래스만 정적 필드로 미리 바인딩" 모양으로 정정. **이벤트 -바인딩은 `On.EventName` 도트액세스를 접고 PA님 방식(평범한 문자열 키 + -`ReflectionService` 기반 자동 판별)으로 전환** — Store의 dot-access는 실질적 -타입 이득이 있어 그대로 유지, 이벤트만 예외. 전파 모델(push-invalidate/ -pull-recompute)과 라이프사이클(GC-native)은 PA님 코드가 반례처럼 보였으나 -(각각 파생 개념이 없는 단순 pub-sub, 전부 수동 해제) 재검토 후 **기존 -확정 유지** — 라이프사이클은 나중에 하이브리드로 확장 가능한 여지만 기록. -OOP 회피 결정은 PA님의 `class.luau`도 같은 체이닝 상속 문제를 보여 오히려 -보강됨. **더 이상 열려있는 핵심 설계 질문은 없음** — 남은 건 API 표면 이름뿐 -(위 "지금 할 일" 참고). 그 외 자잘한 정정들(Slot retract=폐기 확정, Pipe COW -후보 폐기 등)은 각 문서에 바로 반영해둠 — 재조사 불필요. - -이전 세션(2026-08-03) 종료 시점 메모: `.claude/` 전체 스캐폴드 + 대부분의 -핵심 아키텍처 결정을 완료, 로컬 git 저장소 초기화+첫 커밋(원격 없음, -`SAFETY.md` 참고 — 원격은 사용자가 제한 계정을 마련해줘야 추가 가능). +용어 정리 제안 진행 중인 점은 위 "지금 할 일" 3번 참고. From 470f188d0fc7d03db9ce179c86b23cc6f1f80086 Mon Sep 17 00:00:00 2001 From: qwreey Date: Tue, 4 Aug 2026 17:11:23 +0900 Subject: [PATCH 05/62] =?UTF-8?q?=EB=A1=9C=EB=93=9C=EB=A7=B5=20=EC=9D=B8?= =?UTF-8?q?=EC=88=98=EC=9D=B8=EA=B3=84=20=EB=9D=BC=EC=9A=B4=EB=93=9C(2026-?= =?UTF-8?q?08-04)=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81=20=E2=80=94=20?= =?UTF-8?q?ROADMAP.md=20=EC=8B=A0=EC=84=A4,=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EC=A2=85=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit component-composition-plan을 research/에서 base/로 승격하고, 구현 착수 전 리스크 감사에서 나온 M0 스파이크 항목(Store/State propagation, dispatch, 컴포넌트 경계 named-parameter 전달)을 문서에 반영. quad-base 테스트 mock 방향(Vide 선례 채택) 확정. --- .claude/base/architecture.md | 41 ++- .claude/base/bind-system-plan.md | 10 + .claude/base/component-composition-plan.md | 259 ++++++++++++++++++ .claude/base/modifier-plan.md | 12 + .claude/base/slot-plan.md | 30 +- .claude/question.md | 24 +- .../research/component-composition-plan.md | 110 -------- CLAUDE.md | 97 ++++--- ROADMAP.md | 116 ++++++++ 9 files changed, 532 insertions(+), 167 deletions(-) create mode 100644 .claude/base/component-composition-plan.md delete mode 100644 .claude/research/component-composition-plan.md create mode 100644 ROADMAP.md diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 61fffbb..dd45855 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -157,6 +157,36 @@ quad/ Tween/purity/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을 막지 않음. +## 테스트 전략: 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`로 접근할 @@ -171,8 +201,9 @@ Store와 별개인 가벼운 `Source` 프리미티브를 씀. `store.key` dot-ac ## 아직 미정 (research/로 분리됨) -Tween 플러깅, 이미 생성된 인스턴스에 대한 바인드, 컴포넌트가 modifier/Ref를 -경계 너머로 어떻게 전달하는지 — `.claude/research/` 각 문서 참고, 전체 색인은 -`.claude/README.md`. 바인드 디스패치/Slot/모듈 라이프사이클은 위 "구현 착수" -섹션대로 확정되어 `.claude/base/`로 승격됨(`bind-system-plan.md`/ -`module-lifecycle-plan.md`/`slot-plan.md`). +Tween 플러깅, 이미 생성된 인스턴스에 대한 바인드 — `.claude/research/` 각 +문서 참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈 +라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는 +위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨 +(`bind-system-plan.md`/`module-lifecycle-plan.md`/`slot-plan.md`/ +`modifier-plan.md`/`component-composition-plan.md`). diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index e5358d1..7c31475 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -99,6 +99,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 - **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. +- **일반적인 무한루프 방어(사이클 감지 등)는 하지 않기로 확정(2026-08-04, + 로드맵 인수인계 라운드)**: 우선순위 스캔+재귀 `process` 구조 자체는 핸들러가 + 규율을 안 지키면(예: 값을 좁히지/변형하지 않고 같은 값을 그대로 다시 + `process`에 넘김) 무한루프에 빠질 수 있음 — 하지만 이건 base가 방어 로직을 + 둬야 할 문제가 아니라 오작동하는 handler/provider(`quad-roblox` 등) 쪽 + 버그로 간주 — **사용자 확정**("입력된 값이 다시 입력되면 무한루프 + 빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나 + 프로바이더가 잘못 짠 코드일테니까"). Tween의 store-bind 재귀 케이스(위 + 78-79행)처럼 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 + 있는 상황이 아니라고 판단해 별도 가드 없이 진행. ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md new file mode 100644 index 0000000..4690c10 --- /dev/null +++ b/.claude/base/component-composition-plan.md @@ -0,0 +1,259 @@ +# 컴포넌트화 (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, +`base/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)는 이번 논의에서 다루는 Source 양방향 프록시가 대체. + +## 수렴된 결론 + +### 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(독립이든 Store 소속 `StoreSource` 프록시든)는 파생이 아니라 +항상 원본 슬롯 하나를 직접 가리키므로 쓰기가 의미 있음 — **사용자 확정** +("맞음. 확실해"). + +### 3. `StoreSource`: Source를 인터페이스+구현체로 두고, Store 키에서 그 인터페이스를 구현하는 얇은 프록시를 받음 + +- **Source = 인터페이스이자 구현체**: 독립 생성자 `Source(initial)`가 기본 + 구현체, `store:GetSource("key")`(가칭)류 접근자가 반환하는 값은 같은 + 인터페이스를 구현하는 별도의 얇은 프록시(`StoreSource`) — 읽기는 + `store.key`로, 쓰기는 `store.key = v`로 위임. **내부 Source 객체를 그대로 + 노출하지 않음** — 그러면 "쓰기는 오직 Store의 `__newindex`뿐"이라는 기존 + 확정과 새 쓰기 경로가 충돌하게 됨. +- **캐시 안 함**: State가 이미 "매번 새로 만듦, store에 캐시 안 됨"으로 + 확정돼 있어 일관성 + 엔지니어링 비용 둘 다 이쪽이 쌈 — **사용자 확정** + ("그냥 엔지니어링적으로 비용이 싼거 택해"). + +### 4. Source 직접 전달(양방향)은 핸들러 계약 확장 없이 타입 유니온으로 처리 — 단, 실사용 범위는 좁음 + +- 핸들러가 값을 받을 때 `Source | State` 유니온으로 받고, 내부에서 + 타입 체크만 하면 됨(Source면 인스턴스 변경 이벤트에 걸어 역방향 쓰기까지 + 처리, State면 읽기만) — `isHandlable`/`priority`/`process`/`retract` 4종 + 계약에 5번째 항목을 추가할 필요 없음. Source 자체가 계산이 없는 원천이라 + 가능한 단순화 — **사용자 확정**("그냥 타입 상 source를 받거나 state를 + 받거나 하면 됨. source 자체는 원천이라 컴퓨팅 같은거 없어"). +- **하지만 실사용은 좁을 것으로 예상**: `isEnabled`처럼 여러 조건에 영향 + 받는(=파생된) 값은 애초에 State지 Source가 아니므로 이 경로로 못 넘김. + 즉 Source 직접 전달이 통하는 건 진짜 단순한 1:1 원본-토글 케이스뿐이고, + 일반적인 경우엔 React식 `value(State) + onChange(callback)` 패턴이 기본 + — **사용자 확정**("isenabled가 여러 조건에 영향 받으면 바로 문제가 + 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 + 일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함"). + +### 5. 리프(Roblox 프로퍼티) 바인딩엔 원칙적으로 State만 + +계산된 최종값만 실제로 인스턴스에 반영되어야 하므로, 리프 바인딩은 State가 +일반 경로. Source는 리프 바인딩용 프리미티브가 아니라, 아주 단순한 구조에서 +콜백 보일러플레이트를 줄이기 위한 좁은 용도의 예외 — **사용자 확정** +("리프 바인딩엔 state만 쓰이지 않을까... source는 그냥 아주 단순한 +구조에서 콜백을 넣고 하는 복잡함을 줄이기 위함일 뿐임"). + +## 프레임워크 사례 조사 (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, ... }`) — **사용자 확정** +("결과적으로 함수 구현에선 타입을 멀쩡히 지정하는게 더 중요하니 네임드가 +맞는듯"). 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가 없다"를 표현. 별도 조율 메커니즘 불필요 — **사용자 + 확정**("불가능하진 않고 기술적으로도 충분히 되는 일... 엄청 집중해야할 + 일은 아니지 않을까"). + +이 정리로 원래의 "모호해지는 케이스"는 사라짐: 컴포넌트가 단일 root를 갖는 +한 named parameter로 명확히 전달되고, 단일 root가 없는 컴포넌트(Slot 반환)는 +애초에 그 파라미터를 안 받으므로 모호함이 생길 지점 자체가 없음. 반환값에 +"사후적으로" 뭔가를 꽂아넣는다는 그림 자체가 틀렸던 것 — forwarding은 항상 +컴포넌트가 반환하기 *전에*, 저작자 코드 안에서 일어나는 일이라 어느 root로 +가야 하는지는 저작자가 자기 코드에 뭐라고 쓰느냐로 완전히 결정됨(자동 전파가 +없기 때문에 성립하는 단순함). + +### 3. 여러 modifier를 하나로 합치는 공개 유틸 필요 — `Modifier.Merge`(가칭) + +caller가 named parameter 하나에 여러 modifier를 몰아넣고 싶을 때 +(`Frame{modifier1, modifier2}`의 컴포넌트판)를 위해, 기존 flatten 규칙(배열 +순서상 나중 것이 필드 단위로 이김, `modifier-plan.md` 2번)을 그대로 재사용하는 +결합 함수를 공개 API로 노출: `Modifier.Merge(mod1, mod2, ...) -> Modifier`. +새 병합 규칙이 아니라 이미 확정된 flatten을 함수로 한 번 더 꺼내 쓸 수 있게 +하는 것뿐 — **사용자 요청**("modifier를 합칠 방법도 존재한다면 좋을것 +같아"). `MyComp { Modifier = Modifier.Merge(theme, override) }` → 컴포넌트 +내부는 항상 이미 합쳐진 단일 값만 받으므로 컴포넌트 저작자가 배열 처리를 +신경 쓸 필요 없음. Ref는 필드 충돌 개념이 없어 이 문제 자체가 없음(여러 +Ref를 받으면 그냥 전부 실행하면 됨, `modifier-plan.md` §4-2) — 별도 결합 +유틸 불필요. + +## 남은 열린 질문 (`.claude/question.md`에도 취합, 전부 후순위 — 이름만 남음) + +- **정확한 API 이름**: `Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 + 자체도 불확실 — 아마 불필요), `GetSource` 계열 접근자 이름, `Source` + 독립 생성자 이름, 컴포넌트 경계용 `props.Modifier`/`props.Ref` 필드명, + `Modifier.Merge` 함수명은 전부 가칭. `base/bind-system-plan.md`의 "남은 + 열린 질문" 절(정확한 함수/생성자 이름 미정)과 같은 급의 후순위 항목 — + 구현 단계에서 다른 이름들과 함께 확정. +- **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와 + 무관. diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index c1273bf..e906af7 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -136,6 +136,18 @@ Modifier가 immutable해야 하는 이유(변환마다 clone)와 State가 이미 명시적으로 쓰는 구조라 "암묵적 분기"가 애초에 존재하지 않음 — 새로 결정할 것 없음. +### 7. State가 Modifier를 값으로 담는 것은 UB — 타입으로 막을 것 (2026-08-04, 로드맵 인수인계 라운드) + +Modifier "필드"가 State일 수 있는 것(4-1번)과는 별개로, **State 자체의 +value가 Modifier인 경우**(예: `someState:With(fn)`이 Modifier를 반환)는 +지원 대상이 아님 — Modifier는 "flatten해서 한 번 적용"이 전제인 정적 값인데, +State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어 매번 재-flatten이 +필요해지고, 이는 "정적 merge" 확정(1번)과 정면으로 충돌함 — **사용자 +확정**("state 안에 modifier가 있으면 그건 끔찍히 힘들꺼야... 타입 상 받지 +못하게 만들어야 할 수도 있고"). **UB로 확정, 가능하면 타입 시스템으로 +아예 못 넣게 막을 것**(`State` 같은 조합을 타입 정의 단계에서 +거부) — 런타임 가드가 아니라 타입 차단을 우선 검토. + ## 열린 질문 (`.claude/question.md`에도 취합) - Getter 정확한 이름/모양(`:Get(key)` vs dot-access 겸용) — 후순위, 구현 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 8a143b7..56679c0 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -65,10 +65,27 @@ Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 편하다는 방향. **기울어진 결론**: 별도 "Named Slot" 개념 없이, store나 파라미터로 넘기고 그게 그냥 ref처럼 바인드되는 모양. -**상태 확인(2026-08-04 문서 정리 시점)**: 이 방향은 아래 "열린 질문" 절이나 -`.claude/question.md`의 확정 목록 어디에도 명시적으로 흡수된 흔적이 없음 — -아직 정식 확정 절차(`AskUserQuestion` 등)를 거치지 않은 것으로 보임. **열린 -질문으로 유지**, `.claude/question.md`에도 반영 필요. +**확정(2026-08-04, 로드맵 인수인계 라운드)**: 위 방향 그대로 확정 — 별도 +"Named Slot" 개념 없음, 슬롯 바인드 테이블을 store나 파라미터로 그냥 넘기면 +ref처럼 바인드됨 — **사용자 확정**("A. 맞음. 리프노드에선 그렇게 마운트됨"). +단, 이 확정은 "리프 레벨에서 슬롯 하나가 마운트되는 방식"에 한정 — 여러 +Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래 +참고. + +### 여러 Slot이 섞일 때 순서 보장 — 열린 질문 (2026-08-04 신규) + +`Frame { Slot1, 일반자식, Slot2 }`처럼 Slot과 Slot 사이에 다른 요소가 끼거나 +Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가 +항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지는 아직 설계 안 됨 — +**사용자 확정**("순서도 따름, 만약 웹이라면 순서에 맞춰서 마운트 되어야 하고, +아래쪽 slot은 위쪽 slot의 요소보다 무조건 아래 있어야함... 이게 가능하냐는 +생각해봐야할 이야기임"). Roblox 자체는 z-순서를 주로 `LayoutOrder`/`ZIndex`로 +푸는 편이라 당장 급하게 막히는 지점은 아니지만, `architecture.md`가 명시한 +"quad-base는 다른 렌더 백엔드(GTK, 웹 DOM 등)에서도 재사용 가능해야 한다"는 +전제와 직결되는 문제 — DOM류 백엔드는 형제 순서 자체가 렌더 순서라 이 보장이 +없으면 못 씀. **후순위 열린 질문으로 유지**, Slot 코어 로직 구현 시점에 +다시 볼 것 — M0/M1 단계를 막지는 않음(Roblox 단일 백엔드로는 당장 문제 +없음). ## Slot과 Store 바인드의 관계 (`retract` 순서) @@ -112,3 +129,8 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데 - 재마운트 에러 처리(throw), retract 시 폐기(옮기지 않음) 둘 다 확정. 남은 건 실제 구현 단계에서 이 "폐기" 동작이 실사용에서 불편하지 않은지 재검증하는 정도 — 설계 방향 자체는 더 이상 열려있지 않음. +- "클래스가 슬롯을 받는 방법"(Named Slot 없음)도 확정됨(위 "클래스가 슬롯을 + 받는 방법" 절 참고). +- **여러 Slot이 형제로 섞일 때 순서 보장**은 아직 열려있음(위 "여러 Slot이 + 섞일 때 순서 보장" 절 참고) — Roblox 단일 백엔드로는 급하지 않음, Slot + 코어 로직 구현 시점에 재검토. diff --git a/.claude/question.md b/.claude/question.md index 1cd4770..3fc79b1 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -10,22 +10,7 @@ ## 지금 열려있는 것 (우선순위순) -### 1. [최우선] 컴포넌트 경계에서 modifier/Ref가 어떻게 전달되는가 - -사용자가 "지금 quad에서 가장 문제되는 부분"으로 직접 지목. 컴포넌트가 -플레인 함수이고 반환하는 루트가 여러 개(혹은 Slot으로 갈라지는 구조)일 때, -호출부가 넘긴 modifier/Ref가 "어느 루트로 가야 하는지" 모호해지는 케이스가 -있음. Jetpack Compose는 언어 강제가 아니라 "컴포저블은 `modifier` 파라미터를 -받아 루트에 적용해야 한다"는 순수 관례(+린트)로 풂 — quad도 비슷한 관례 -기반으로 갈 수 있어 보이나 다중 루트 케이스는 미정. - -**주의**: modifier "값 자체"가 어떻게 동작하는지(정적 merge, immutable+clone -체이닝, State 필드 지원)는 이미 완전히 확정됨(`base/modifier-plan.md`) — 이 -질문은 그것과 별개로 "경계를 어떻게 통과하느냐"만 다룸, 혼동하지 말 것. - -→ 상세/배경: `research/component-composition-plan.md`. - -### 2. 용어 정리 (사용자 요청, 진행 중) +### 1. 용어 정리 (사용자 요청, 진행 중) 사용자 원 메모: "quad는 register라던가 좀 부정확하거나 느낌이 바로 와닿지 않던 용어들이 많음 — 전체적 용어를 보고 생각해볼래? 제안을 줘, 나도 같이 @@ -56,13 +41,17 @@ - `Store`/`Source`/`Modifier`/`Ref`/`process`/`retract`/`isHandlable`은 업계 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. -### 3. 낮은 우선순위 +### 2. 낮은 우선순위 - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 착수를 막지 않음. - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — `base/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록. +- **여러 Slot이 형제로 섞일 때 순서 보장** — `base/slot-plan.md`의 "여러 + Slot이 섞일 때 순서 보장" 절 참고. Roblox 단일 백엔드로는 급하지 않음 + (LayoutOrder/ZIndex로 대부분 해결), 다른 백엔드(웹 DOM 등) 재사용성과 + 직결되는 문제라 Slot 코어 로직 구현 시점에 재검토. ## 참고: 지금까지 확정된 것 (요약) @@ -79,6 +68,7 @@ | Slot 재조정, 재마운트 시 throw, retract=폐기 | `base/slot-plan.md` | | `Connected`+GC 라이프사이클 패턴 | `base/lifecycle-pattern.md` | | Modifier(정적 merge, immutable 체이닝, State 필드 지원) | `base/modifier-plan.md` | +| 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Merge`) | `base/component-composition-plan.md` | | 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | | Fusion/Vide 비교 리서치(주의: 일부 서술은 이후 라운드에서 뒤집힘, 문서 내 정정 표시 참고) | `base/comparison-fusion-vide.md` | | v1 내부 동작 스냅샷 | `base/quad-v1-architecture.md` | diff --git a/.claude/research/component-composition-plan.md b/.claude/research/component-composition-plan.md deleted file mode 100644 index 77f4185..0000000 --- a/.claude/research/component-composition-plan.md +++ /dev/null @@ -1,110 +0,0 @@ -# 컴포넌트화 (Roblox 기본 오브젝트 이외의 사용자 정의 컴포넌트) - -**상태**: research — 2026-08-04 세션 채팅 논의에서 핵심 골격 수렴, 세부 -API 이름/modifier·Ref passthrough는 미정. 사용자가 "지금 quad에서 가장 -문제되는 부분"으로 직접 지목한 주제. `base/bind-system-plan.md`의 -Store/State/Source 온톨로지가 먼저 확정된 뒤에야 이 논의가 열림 — 그 -문서가 선행 컨텍스트. - -## 문제 - -v1의 `Class.Extend()`(Init/Render/AfterRender/Getter/Setter/UpdateTriggers, -`base/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)는 이번 논의에서 다루는 Source 양방향 프록시가 대체. - -## 수렴된 결론 - -### 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(독립이든 Store 소속 `StoreSource` 프록시든)는 파생이 아니라 -항상 원본 슬롯 하나를 직접 가리키므로 쓰기가 의미 있음 — **사용자 확정** -("맞음. 확실해"). - -### 3. `StoreSource`: Source를 인터페이스+구현체로 두고, Store 키에서 그 인터페이스를 구현하는 얇은 프록시를 받음 - -- **Source = 인터페이스이자 구현체**: 독립 생성자 `Source(initial)`가 기본 - 구현체, `store:GetSource("key")`(가칭)류 접근자가 반환하는 값은 같은 - 인터페이스를 구현하는 별도의 얇은 프록시(`StoreSource`) — 읽기는 - `store.key`로, 쓰기는 `store.key = v`로 위임. **내부 Source 객체를 그대로 - 노출하지 않음** — 그러면 "쓰기는 오직 Store의 `__newindex`뿐"이라는 기존 - 확정과 새 쓰기 경로가 충돌하게 됨. -- **캐시 안 함**: State가 이미 "매번 새로 만듦, store에 캐시 안 됨"으로 - 확정돼 있어 일관성 + 엔지니어링 비용 둘 다 이쪽이 쌈 — **사용자 확정** - ("그냥 엔지니어링적으로 비용이 싼거 택해"). - -### 4. Source 직접 전달(양방향)은 핸들러 계약 확장 없이 타입 유니온으로 처리 — 단, 실사용 범위는 좁음 - -- 핸들러가 값을 받을 때 `Source | State` 유니온으로 받고, 내부에서 - 타입 체크만 하면 됨(Source면 인스턴스 변경 이벤트에 걸어 역방향 쓰기까지 - 처리, State면 읽기만) — `isHandlable`/`priority`/`process`/`retract` 4종 - 계약에 5번째 항목을 추가할 필요 없음. Source 자체가 계산이 없는 원천이라 - 가능한 단순화 — **사용자 확정**("그냥 타입 상 source를 받거나 state를 - 받거나 하면 됨. source 자체는 원천이라 컴퓨팅 같은거 없어"). -- **하지만 실사용은 좁을 것으로 예상**: `isEnabled`처럼 여러 조건에 영향 - 받는(=파생된) 값은 애초에 State지 Source가 아니므로 이 경로로 못 넘김. - 즉 Source 직접 전달이 통하는 건 진짜 단순한 1:1 원본-토글 케이스뿐이고, - 일반적인 경우엔 React식 `value(State) + onChange(callback)` 패턴이 기본 - — **사용자 확정**("isenabled가 여러 조건에 영향 받으면 바로 문제가 - 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 - 일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함"). - -### 5. 리프(Roblox 프로퍼티) 바인딩엔 원칙적으로 State만 - -계산된 최종값만 실제로 인스턴스에 반영되어야 하므로, 리프 바인딩은 State가 -일반 경로. Source는 리프 바인딩용 프리미티브가 아니라, 아주 단순한 구조에서 -콜백 보일러플레이트를 줄이기 위한 좁은 용도의 예외 — **사용자 확정** -("리프 바인딩엔 state만 쓰이지 않을까... source는 그냥 아주 단순한 -구조에서 콜백을 넣고 하는 복잡함을 줄이기 위함일 뿐임"). - -## 아직 열린 질문 (`.claude/question.md`에도 취합) - -- **modifier/Ref가 컴포넌트 경계를 어떻게 통과하는가**: 컴포넌트가 플레인 - 함수이고 반환하는 루트가 여러 개(혹은 Slot으로 갈라지는 구조)일 때, 호출부가 - 넘긴 modifier/Ref가 "어느 루트로 가야 하는지" 모호해지는 케이스가 있음. - Jetpack Compose는 언어 강제가 아니라 "컴포저블은 `modifier` 파라미터를 - 받아 루트에 적용해야 한다"는 순수 관례(+린트)로 풂 — quad도 비슷한 관례 - 기반으로 갈 수 있어 보이나 다중 루트 케이스는 미정. **주의: 이건 "경계를 - 어떻게 통과하는가"의 문제이고, "modifier 값 자체가 어떻게 동작하는가"(정적 - merge, immutable 체이닝)는 `research/modifier-plan.md`로 이미 별도 확정됨 - — 둘을 혼동하지 말 것.** -- **정확한 API 이름**: `Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 - 자체도 불확실 — 아마 불필요), `GetSource` 계열 접근자 이름, `Source` - 독립 생성자 이름은 전부 가칭. `base/bind-system-plan.md`의 "남은 열린 - 질문" 절(정확한 함수/생성자 이름 미정)과 같은 급의 후순위 항목. -- **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와 - 무관. diff --git a/CLAUDE.md b/CLAUDE.md index 9c38224..2b66eb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,11 +22,12 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 **지금은 설계/계획 단계이고 구현은 아직 시작 전** — 저장소 루트에 실제 소스 코드(`src/` 등)가 없음. 핵심 아키텍처(Store 책임 분리, `process`/`retract` 디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘, -컴포넌트=플레인 함수)는 전부 `.claude/base/`에 문서로 확정돼 있음 — 먼저 -`.claude/base/architecture.md`를 읽을 것. **단, "핵심 설계 질문이 더 이상 -없다"는 뜻은 아님** — 컴포넌트화(특히 modifier/Ref가 컴포넌트 경계를 어떻게 -통과하는지)는 사용자가 직접 "지금 quad에서 가장 문제되는 부분"으로 지목한 -채 아직 열려있음, 아래 "지금 할 일" 참고. +컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`에 +문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가 +직접 "지금 quad에서 가장 문제되는 부분"으로 지목했던 컴포넌트화(특히 +modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션에서 수렴 +완료(`base/component-composition-plan.md`) — 남은 핵심 설계 질문은 없고, +용어 정리(진행 중)와 실제 스캐폴딩만 남음, 아래 "지금 할 일" 참고. 이전에 시도했다 폐기한 v2 재작성 시도(`.claude/initreq/quad2-try`)도 리서치 완료 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` copy-on-write 절충안은 확인된 @@ -39,8 +40,8 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 `.claude/base/architecture.md`를 읽을 것. - `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 지금은 `tween-plan.md`(세부 옵션만 남음), `existing-instance-bind-plan.md`(급하지 - 않음), `component-composition-plan.md`(**사용자가 최우선으로 지목한 열린 - 주제**) 세 개뿐. + 않음) 두 개뿐 — `component-composition-plan.md`는 2026-08-04 세션에 수렴 + 완료돼 `base/`로 승격됨. - `.claude/qa-request/`, `.claude/archive/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, 지금은 비어있음. - `.claude/initreq/` — 클론해둔 참고 레포(quad v1, Fusion, Vide, rbvm, tbox, @@ -48,6 +49,9 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 `.gitignore`로 커밋 제외됨** — 내용을 다른 곳으로 옮기지 말고 항상 원본 그대로 둘 것. 리서치가 더 필요하면 이 폴더를 다시 파고들 것. - `.claude/question.md` — 사용자가 답해야 할 질문 전체 취합(우선순위순). +- 루트 `ROADMAP.md` — 설계 단계 종료 후 실제 구현 순서(M0, M1, ... 마일스톤 + + todo 체크박스). "무엇을 확정했는가"는 `.claude/base/`가 소스, "어떤 순서로 + 만드는가"는 이 문서가 소스 — 헷갈리지 말 것. - 루트 `HUMAN_TODO.md` — 사람만 할 수 있는 일(로컬 GUI 조작, 스케줄/루프 설정 등). @@ -87,28 +91,31 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 ## 지금 할 일 (우선순위순) -1. **컴포넌트화 논의 계속 — 사용자가 직접 "가장 문제되는 부분"으로 지목.** - `research/component-composition-plan.md` 참고. 핵심 골격(컴포넌트=플레인 - 함수, State/Source 읽기·쓰기 경계, `StoreSource` 프록시)과 Modifier - 메커니즘 자체(`base/modifier-plan.md`, 완전 확정)는 수렴됨 — 남은 건 - **modifier/Ref가 컴포넌트 경계(특히 다중 루트)를 어떻게 통과하는가**라는 - 진짜 설계 질문(이름 문제가 아님). 다음 세션에서 이걸 이어서 파고들 것. -2. **실제 스캐폴딩.** 소스 트리 구조는 문서로 이미 확정됨(`base/ - architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절) — `quad-base/`, - `quad-roblox/` 폴더, 각각의 `wally.toml`, 루트 `default.project.json`, - `.luaurc`를 만들 것. 1번의 컴포넌트 경계 논의가 `DI`/`Modifier` 모듈 - 설계에 영향을 주므로, 그 결론이 안 나온 상태에서도 나머지 구조(Store/ - State/Source, 디스패치 엔진, Slot)는 그대로 스캐폴딩 가능 — 막을 필요 - 없음(`architecture.md`에도 명시). 이 시점부터 `qa-request/`/`archive/` - 폴더가 실제로 쓰이기 시작함. -3. **용어 정리 — 사용자가 별도로 요청, 진행 중.** "register"(v1) 같이 +1. **구현 시작 — 루트 `ROADMAP.md`의 M0부터.** 설계 단계는 2026-08-04 + 로드맵 인수인계 라운드로 종료, 다음 세션은 바로 `ROADMAP.md` M0(스켈레톤+ + 기술검증 스파이크)부터 시작. M0는 "진짜" 마일스톤이 아니라, 지금까지 + 추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 세 가지(Store/State + propagation, 재귀 process/retract 디스패치, 컴포넌트 경계 named-parameter + 전달)를 던지는 코드로 검증하는 단계 — 여기서 걸리면 `base/` 문서를 그 + 자리에서 고치는 게 정상. M0 통과 후 M1(실제 스캐폴딩: `quad-base/`, + `quad-roblox/` 폴더 + `wally.toml`/`default.project.json`/`.luaurc` + + quad-base용 최소 mock 테스트 하네스)으로 진행 — 소스 트리 자체는 이미 + 확정됨(`base/architecture.md` "구현 착수" 절). 이 단계부터 + `qa-request/`/`archive/` 폴더가 실제로 쓰이기 시작함. **세부 순서/todo는 + `ROADMAP.md`가 소스** — 여기서 반복 안 함. +2. **용어 정리 — 사용자가 별도로 요청, 진행 중.** "register"(v1) 같이 부정확한 이름들을 전체적으로 재검토하자는 요청 — 1차 제안 완료(우선순위 순: `State`가 React/Vue식 "쓸 수 있는 로컬 상태"라는 통상 의미와 반대라 가장 위험, `DI`가 Dependency Injection 축약어와 충돌, `PerInstanceState`가 핵심 프리미티브 `State`와 이름 충돌 — 세부는 `.claude/question.md` 참고), - 사용자와 같이 계속 논의 필요. -4. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 + 사용자와 같이 계속 논의 필요. 컴포넌트 경계용 `props.Modifier`/`props.Ref`/ + `Modifier.Merge` 같은 새 가칭들도 이 정리에 합류 대상. +3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 필요, 구현 착수를 막지 않음. +4. **[백로그] 범용 렌더 디버깅 도구로서의 quad-mock.** 1번의 quad-base 테스트용 + mock과는 별개 — 정적 스냅샷을 넘어 Tween mock 같은 동적 동작까지 지원하는 + 더 큰 스코프의 디버깅 도구(`architecture.md` "테스트 전략" 절 백로그 참고). + 효용성 봐가며 나중에 검토, 지금 당장 설계할 필요 없음. 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). @@ -125,12 +132,19 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 경우의 setter/getter 동작까지 전부 확정(`base/modifier-plan.md`, 새로 base 승격). 이 논의에서 "관측해야 실체화된다"는 프로젝트 전역 원칙도 명문화(`bind-system-plan.md`). -- **컴포넌트화 논의 시작, 아직 미완** — v1의 `Class.Extend()` 자동-store - 매직은 폐기하고 React식으로 값을 명시적으로 전달하는 방향으로 수렴, - `StoreSource`(Source를 인터페이스+구현체로 보고 Store 키에서 얇은 - 프록시로 얻는 것) 아이디어까지 나왔지만 modifier/Ref의 컴포넌트 경계 - 통과 방식은 미해결(`research/component-composition-plan.md`, 위 "지금 - 할 일" 1번). +- **컴포넌트화 논의, 같은 날 후속 세션에서 완결** — v1의 `Class.Extend()` + 자동-store 매직은 폐기하고 React식으로 값을 명시적으로 전달하는 방향으로 + 수렴, `StoreSource`(Source를 인터페이스+구현체로 보고 Store 키에서 얇은 + 프록시로 얻는 것) 아이디어 확정. 마지막 미결이던 "modifier/Ref의 컴포넌트 + 경계 통과"도 후속 세션에서 풀림: Compose/Fusion/Vide/v1 4개 선례를 + 서브에이전트로 병렬 조사한 결과 전부 named parameter로 경계를 넘기는 + 패턴에 수렴한다는 게 확인됐고, "컴포넌트가 여러 루트를 반환한다"는 + 프레이밍 자체가 (a) Luau가 tail position 밖 다중 리턴을 지원 안 해서 + 불필요한 개념과 (b) 이미 있는 Slot 메커니즘을 섞은 것이었음이 드러나 + 정리됨 — 결론: 경계는 named parameter(`props.Modifier`/`props.Ref` + 가칭), "다중 루트"라는 별도 개념은 폐기, 여러 modifier를 하나로 합치는 + `Modifier.Merge`(가칭) 유틸 추가. `research/component-composition-plan.md` + → `base/component-composition-plan.md`로 승격 완료. - **문서 전체 감사 및 정리** — `.claude/` 코퍼스 전체(약 15개 문서)를 서브에이전트로 감사해 여러 라운드에 걸쳐 쌓인 모순/중복/stale 마커를 대거 발견하고 수정(예: 이벤트 dot-access 확정 여부가 문서 내에서 서로 @@ -142,4 +156,25 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 git log와 각 `base/`/`research/` 문서 안의 라운드 표시(예: "2026-08-04 3차 라운드")를 참고할 것, 여기서 전부 반복하지 않음. -용어 정리 제안 진행 중인 점은 위 "지금 할 일" 3번 참고. +**같은 날 로드맵 인수인계 라운드 — 설계 단계 마무리, 구현 준비 완료**: +- **quad-base 테스트 mock 방향 확정**: Vide 선례(`test/mock.luau`, ~300줄, + 순수 `luau` CLI, Studio 불필요) 그대로 채택, 스코프는 정적 디버깅 한정(Tween + 같은 동적 동작 제외), quad-roblox로 작성한 컴포넌트가 mock에서도 그대로 + 돌아가야 한다는 요구 없음(단순하게 감) — `architecture.md` "테스트 전략" + 절. 범용 렌더 디버깅 도구(Tween mock 포함)는 별개로 백로그. +- **구현 전 리스크 감사**: `.claude/base/` 전체 + 남은 `research/`를 + 서브에이전트로 감사해 "실제 Luau 접촉 없이 추론만으로 확정된 것" 3개 + (Store/State 반응형 코어, 디스패치 엔진, 컴포넌트 경계 modifier/Ref)를 + 식별 — 이것들은 M0 스파이크로 검증하기로 함(아래). 감사 중 `slot-plan.md`가 + 스스로 "정식 확정 안 됨"이라 표시해뒀던 "클래스가 슬롯을 받는 방법"(Named + Slot 없음)도 이번에 정식 확정, 대신 "여러 Slot이 형제로 섞일 때 순서 보장" + 이라는 새 하위 질문이 열림(다중 백엔드 관점, Roblox만이면 급하지 않음). + `State` 조합은 UB로 확정해 타입으로 막기로 함(`modifier-plan.md` + 7번), 디스패치 엔진의 일반적 무한루프는 방어 로직 없이 provider 버그로 + 간주하기로 확정(`bind-system-plan.md`). +- **루트 `ROADMAP.md` 신설** — M0(스켈레톤+기술검증 스파이크, "진짜" + 마일스톤 아님)부터 M11(Tween)까지 + 병행 가능 항목 + 백로그로 구성된 실행 + 계획, todo 체크박스 포함. 오늘은 문서 준비만 — **다음 세션이 M0부터 실제 + 시작**. + +용어 정리 제안 진행 중인 점은 위 "지금 할 일" 2번 참고. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..8b48962 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,116 @@ +# 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면 전파 중단되는지) +- [ ] `process`/`retract` 재귀 재-process 디스패치를 실제로 짜보기(store-bind + 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) +- [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, + `export type Params = {...}`로 타입 체크되는지 확인 + (`component-composition-plan.md` 최종 결론 1번) +- [ ] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서 + `.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`(`process`/`retract` 엔진, `isHandlable` 우선순위 스캔) +- [ ] `Handler.luau`(핸들러 계약 타입) +- [ ] mock 대상 테스트 + +## M3 — Store/State/Source + +- [ ] `Source.luau`/`State.luau`/`Store.luau` +- [ ] `store.key` dot-access 타입 추론 확인 +- [ ] mock 대상 테스트 + +## M4 — 첫 end-to-end 반응형 업데이트 + +- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관) +- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" 확인 + +## 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 + +- [ ] "여러 Slot이 형제로 섞일 때 순서 보장" 열린 질문 확인(`slot-plan.md`) — + Roblox 단일 백엔드로는 급하지 않으면 스킵하고 진행 가능 +- [ ] base `Dispatch/Slot.luau`(추상 재조정) + quad-roblox `Handlers/Slot.luau` + (실제 Parent 조작) + +## M7 — Modifier + +- [ ] flatten-before-dispatch, immutable `table.clone` 체이닝 +- [ ] `Modifier.Merge`(가칭) +- [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) + +## M8 — Ref + +- [ ] `CreatedRef` 메커니즘(숫자 슬롯 참가자) +- [ ] `LifetimeHandle` 인터페이스 + quad-roblox 실제 구현(Instance 생존 확인) +- [ ] `PerInstanceState` 인터페이스 + quad-roblox 실제 구현(weak-keyed table) + +## M9 — 컴포넌트 합성 레이어 + +- [ ] 플레인 함수 컴포넌트 관례 문서화/예제 +- [ ] `props.Modifier`/`props.Ref` 전달 관례를 정식 컴포넌트로 검증(M0 + 스파이크를 정식화) + +## M10 — Event / Attribute / Tag + +- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) +- [ ] `Handlers/Attribute.luau` +- [ ] `Handlers/Tag.luau`(`CollectionService`) + +## M11 — Tween + +- [ ] `research/tween-plan.md` 남은 옵션 이름 확정(구조는 이미 확정) +- [ ] `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러, 기본 오버라이드 + Cancel) + +## 특정 마일스톤에 안 묶이고 병행 가능 + +- [ ] 용어 정리 스윕 — `State`/`DI`/`PerInstanceState`/`Slot` 등 + (`.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과는 별개) +- [ ] v1 마이그레이션 가이드 + `objectListClass.__newIndex` 오타 기능 재현 테스트 +- [ ] Slot 형제 순서 보장(다중 백엔드 관점) — Roblox만이면 급하지 않음 From bc0a8b9f5f94bf9c2b042ff3c18284cff089646b Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 15:23:18 +0900 Subject: [PATCH 06/62] =?UTF-8?q?quad-debug/UI=20=EC=88=8F=ED=95=B8?= =?UTF-8?q?=EB=93=9C/Attribute=20=ED=83=80=EC=9E=85=20=EB=85=BC=EC=9D=98(2?= =?UTF-8?q?026-08-06)=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - research/debug-tooling-plan.md 신설: 런타임 디버깅 플러그인 quad-debug 설계 — BindableEvent/Function이 Studio 플러그인↔Play 중 게임 경계를 넘는지 실측 검증 완료, 채널 위치/페이로드 제약/UUID 기반 on-demand compute/Element Inspector/Explorer-플러그인 트리 동기화까지 정리 - research/ui-shorthand-plan.md 신설: v1 Corner/PaddingAll/Scale 인라인 숏핸드 조사, quad-v2 포팅 확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), quad-roblox 코어 직접 포함 원칙 확정 - research/documentation-plan.md 신설: UI 네이밍 컨벤션 + Store 부작용을 게임 시스템에서 쓰는 패턴 문서화 뼈대 - base/bind-system-plan.md: Attribute 특수 키 타입 파라미터화 신규 논의 추가 - base/modifier-plan.md, README.md, question.md, ROADMAP.md, CLAUDE.md: 위 신규 문서 색인/요약 반영 Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 3 + .claude/base/bind-system-plan.md | 39 ++ .claude/base/modifier-plan.md | 5 + .claude/question.md | 21 + .claude/research/debug-tooling-plan.md | 524 +++++++++++++++++++++++++ .claude/research/documentation-plan.md | 61 +++ .claude/research/ui-shorthand-plan.md | 102 +++++ CLAUDE.md | 99 ++++- ROADMAP.md | 5 + 9 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 .claude/research/debug-tooling-plan.md create mode 100644 .claude/research/documentation-plan.md create mode 100644 .claude/research/ui-shorthand-plan.md diff --git a/.claude/README.md b/.claude/README.md index a7b72cb..b37aa38 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -42,6 +42,9 @@ | `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel | 중 — 세부 옵션만 남음 | | `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, `StoreSource` 프록시 — 핵심 골격 수렴, modifier/Ref가 컴포넌트 경계를 어떻게 통과하는지만 남음 | 상 — 사용자가 "가장 문제되는 부분"으로 직접 지목 | +| `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | +| `documentation-plan.md` | UI 네이밍 컨벤션 문서 + Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 — 뼈대만, `debug-tooling-plan.md` 논의에서 파생 | 하 — 착수 시점 미정, 뼈대만 기록해둔 상태 | +| `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | ## 참고 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 7c31475..c70558f 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -506,6 +506,45 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 - **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지** (위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). +## Attribute 특수 키 — 타입 파라미터화 (2026-08-06, 신규 논의) + +**상태**: 미확정, 사용자가 이번에 새로 제기 — 이전에 기록된 적 없음 +(`architecture.md` 4번 항목의 `[Attribute "Name"]`은 특수 DI 키의 존재만 +확정했을 뿐, 타입을 어떻게 표현할지는 다룬 적 없었음). + +**문제**: 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 타입 지원까지 감안하면 그 결정의 근거가 +한층 더 탄탄해짐). + +**후보 두 가지**: +- `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — + 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. +- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 + 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ + `InstanceAttribute` 등). + +**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 +패턴(위 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 +문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 필드로 나눌지" +고민이 있었고, 결론은 **둘 다**(`new(className)` 제네릭 +생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 같은 +모양을 재사용하면 자연스러울 가능성 — `Attribute("name")` 제네릭을 +기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ +`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/ +`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 단 이건 사용자 +확인 전 소견일 뿐 — `.claude/question.md`에 반영, 다음 세션에서 사용자 +판단 필요. + ## 남은 열린 질문 (`.claude/question.md`에도 취합) 이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index e906af7..82274b7 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -126,6 +126,11 @@ PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/bind-sys "이벤트 바인딩 정정" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.) +`Modifier.Rounded(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 +`Corner` 특수 프로퍼티 선례, 핸들러 배치 소견)는 +`research/ui-shorthand-plan.md` 참고 — 이 문서는 Modifier 값 자체의 +동작만 다루므로 분리. + ### 6. State/Pipe 쪽엔 영향 없음 — 이미 있던 결정의 재확인일 뿐 Modifier가 immutable해야 하는 이유(변환마다 clone)와 State가 이미 diff --git a/.claude/question.md b/.claude/question.md index 3fc79b1..2e1d142 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -52,6 +52,27 @@ Slot이 섞일 때 순서 보장" 절 참고. Roblox 단일 백엔드로는 급하지 않음 (LayoutOrder/ZIndex로 대부분 해결), 다른 백엔드(웹 DOM 등) 재사용성과 직결되는 문제라 Slot 코어 로직 구현 시점에 재검토. +- **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. + 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 + 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 + 다 해소됨, 남은 건 세부 API 이름과 "이벤트 함수가 self로 instance를 + 읽는 게 quad 관습"이라는 언급 확인(그 문서 "열린 질문" 절 참고)뿐. + 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정한 건 + 여전함 — base 설계(M2 Dispatch/M3 Source/M5 DI 생성자) 시점에 훅 확장 + 지점만 고려해두면 됨. +- **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 + 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 + 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. +- **Attribute 특수 키 타입 파라미터화** — `base/bind-system-plan.md` + "Attribute 특수 키" 절(2026-08-06 신규). `[Attribute<> "name"]` + 제네릭 스타일 vs `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 + 중 뭘로 갈지 — 소견은 DI 인스턴스 생성 패턴처럼 "제네릭 하나 + 자주 + 쓰는 타입만 정적 지름길" 절충이지만 확정 아님, M10(Handlers/Attribute) + 착수 전 아무 때나 확인해도 됨. +- **UICorner/UIPadding/UIScale 인라인 편의 키 세부** — `research/ + ui-shorthand-plan.md`. 기능 필요 여부(여전히 필요로 재확정)·메커니즘 + (Handler)·패키지 배치(quad-roblox 코어)는 확정, 남은 건 이름 재검토 + (용어 정리 합류)와 `RoundSize`(이미지 라운드) 대체 방식뿐. ## 참고: 지금까지 확정된 것 (요약) diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md new file mode 100644 index 0000000..c3dd794 --- /dev/null +++ b/.claude/research/debug-tooling-plan.md @@ -0,0 +1,524 @@ +# 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` 자동 생성 방식은 기각 — 사용자 정정**: 처음 + 구상은 quad-debug-roblox가 초기화 시 `ReplicatedStorage` 밑에 잘 + 알려진 이름으로 Bindable을 만들어 노출하는 것이었으나, **이건 + 개발자가 의도하지 않은 Instance를 게임 트리에 주입하는, 부작용이 + 큰 행위라 기각**. 대신 Bindable을 **quad 모듈 자신의 Instance + 트리 안**(quad가 이미 설치돼 있는 위치 그대로, 새 위치를 따로 + 안 만듦)에 두고, `CollectionService` 태그로 노출 — 플러그인은 + quad가 어디 설치됐는지 몰라도 `CollectionService:GetTagged(tag)`로 + 바로 찾음(`GetDescendants()`로 전체 트리를 훑어 필터링할 필요 + 없음 — 사용자가 "roblox query descendants" 관련해서 짚어준 더 + 저렴한 방법). 태그를 모듈 자신에 달지 Bindable 각각에 달지는 + 취향 차이 — **사용자 확정**("큰 차이는 없는 엔지니어링 선택"). +- **공통 원칙(사용자 확정)**: 어떤 채널이든 **debug를 안 켰을 땐 CPU/메모리 + 영향이 사실상 없어야 함**(패시브하게 가볍게만 들고 있거나, 아예 아무것도 + 안 함) — 무거운 트레이스 함수 실행은 debug를 켠 다음부터만. 이건 위 + "계측 지점 3곳"의 no-op 훅 upvalue 패턴과 정확히 같은 원칙의 재확인. + +### 6. "관측해야 실체화된다" 원칙 — quad-debug 자신이 위반하면 안 됨 + +`bind-system-plan.md`의 전역 원칙: 어떤 파생값도 `.value`/`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` 네이밍(`research/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`에도 취합) + +기술적 실현 가능성은 전부 해소됨(위 핸드오버 요약) — 아래는 우선순위별로만 +분류, 다음 세션 진행을 막는 항목 없음. + +**확인 필요(다음 세션에서 사용자에게 물어볼 것)** + +- "이벤트 함수들이 실제 instance를 읽을 수 있게 self를 건네받는 게 quad의 + 관습"이라는 언급 — 사용자가 이번 논의 중 언급했으나 본인도 "문서화됐는지 + 모르겠다"고 함. 정확히 어떤 함수(이벤트 핸들러? Compute? `describe` 훅?)에 + 어떤 시그니처로 적용되는 관습인지 확인 후 해당 base 문서 + (`bind-system-plan.md` 등)에 반영 필요 — 지금은 추측성 반영 안 함. + +**세부 API 이름 (후순위, 구현 착수 시점에 자연히 정리)** + +- `describe`(가칭) 5번째 핸들러 훅의 정확한 시그니처/이름. +- Attribute 이름 네임스페이싱(`__quadSource`류)과 노출 정보 범위(스크립트 + 전체 경로를 노출해도 되는지, 파일명만 남길지 등 보안/정보노출 고려). + +**백로그(채택 여부만 남음, 핵심 설계와 무관)** + +- 외부 변경 감지(핵심 설계 방향 4번)를 보조 신호로라도 실제로 켤지 — 타이밍 + 매칭 정확도는 프로토타입 단계에서 검증 필요. +- 서버 확장(RemoteEvent) 여부 — 필요성이 실제로 드러나면 그때. +- quad-mock과 trace 이벤트 스키마를 실제로 공유할지, 아니면 별도로 갈지 — + quad-mock 확장 착수 시점에 재검토. +- 인스턴스 변경을 추적하는 전용 새 Source 메커니즘(다크패턴 방지용으로 + 사용자가 제안했다가 스스로 낮게 평가) — quad가 이미 생성 객체에 + `GetPropertyChangedSignal` 역바인딩 옵션을 제공해서 3줄로 되는 것과 큰 + 차별점이 없어 보임, 타당성 조사만 백로그로. + +**범위 밖 — 별도 문서로 분리됨** + +- 이번 논의에서 파생된 문서화 숙제(UI 네이밍 컨벤션, Store 부작용을 게임 + 시스템에서 깔끔하게 쓰는 패턴)는 quad-debug 범위가 아니라 + `research/documentation-plan.md`로 분리해 뼈대만 기록함. diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md new file mode 100644 index 0000000..8778ebc --- /dev/null +++ b/.claude/research/documentation-plan.md @@ -0,0 +1,61 @@ +# 문서화 전략 계획 (뼈대만) + +**상태**: research — 매우 초기. `research/debug-tooling-plan.md` 논의 +(2026-08-06) 도중 파생된 두 아이디어를 별도로 트래킹할 가치가 있다고 +판단해 뼈대만 기록해둠. 세부 설계는 없음, 착수 시점도 미정 — 사용자가 +"간단히 plan 작성은 하는 게 맞을 듯, 아주 뼈대만 있는 것이라 할지라도 +적어줄 필요는 있겠다"고 판단해서 만든 문서. + +## 1. UI 요소 네이밍 컨벤션 문서 + +**배경**: quad-debug 논의 중 사용자가 직접 겪은 pain point로 드러남 — +Roblox가 Play 중 라이브 UI 편집 도구를 꺼버려서 실제 화면의 UI 요소 +위치를 찾으려면 Explorer를 계속 펼치고 접어야 하는데, quad로 만든 요소는 +보통 이름을 잘 안 지정해서 특히 힘들다는 지적(`debug-tooling-plan.md` +"핵심 설계 방향" 8번, Element Inspector로 부분 완화 예정이지만 근본적으로는 +좋은 네이밍 습관이 있어야 함). + +**뼈대(아직 설계 아님, 물음표만)**: +- 어떤 단위에 이름을 붙이게 유도할까 — 모든 Frame? 컴포넌트 루트만? +- 자동 이름 부여 옵션이 있으면 좋을까(예: 컴포넌트 함수 이름을 기본 + `Name`으로 쓰는 관례 — 있으면 편하지만 매직에 가까워 `architecture.md` + 2번의 "마법 안 쓴다" 원칙과 긴장 있을 수 있음, 검토 필요). +- 어디에 문서화할까 — 퀵스타트 튜토리얼? 별도 스타일 가이드? 린트 규칙으로 + 강제할까(과한 선택지, 참고만)? + +**구체적으로 결정된 하위 규칙 하나(2026-08-06)**: quad가 내부적으로 +자동 생성하는 helper Instance(예: `research/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 권장 패턴 대조 형식이 적당할지, 다른 형식이 나을지도 미정. + +## 다음 단계 + +둘 다 지금 당장 설계할 필요는 없음(`CLAUDE.md` "지금 할 일" 1번 — +구현 착수가 최우선). 사용자 판단이 필요한 것: +- 이걸 `.claude/question.md`/`CLAUDE.md`에 정식 백로그 항목으로 올릴지, + 아니면 이 파일 하나로 충분한지. +- 착수 시점 — quad-debug처럼 "개발 상당 부분 끝난 뒤"로 볼지, 아니면 + M1(스캐폴딩) 즈음부터 조금씩 곁들일지(네이밍 컨벤션은 특히 초기부터 + 적용하는 게 나중에 소급 적용하는 것보다 쌀 수 있음 — 검토 필요). diff --git a/.claude/research/ui-shorthand-plan.md b/.claude/research/ui-shorthand-plan.md new file mode 100644 index 0000000..928e99c --- /dev/null +++ b/.claude/research/ui-shorthand-plan.md @@ -0,0 +1,102 @@ +# UI 편의 숏핸드 (Corner/Padding/Scale 등) — 인라인 적용 계획 + +**상태**: research — 2026-08-06 세션에서 결론까지 남. `Corner`/ +`PaddingAll`/`Scale` 숏핸드 자체는 **여전히 필요**(사용자 재확정, 아래 +"결론" 절 — 이전에 이 문서가 한 차례 "포팅 불필요"로 잘못 정리했던 걸 +정정함). 패키지 배치는 `quad-roblox` 코어 직접 포함으로 확정. + +## 배경 + +사용자 기억: 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종을 지원: + +- `RoundSize = 16` → `ImageLabel`/`ImageButton` 전용, UICorner가 아니라 + 이미지 자체의 9-slice 라운드 처리(`round.SetRound()`) — **UICorner 계열과 + 메커니즘이 다름**. +- `Corner = 8` → 숫자 하나. 기존 `UICorner` 자식이 있으면 재사용, 없으면 + `Instance.new("UICorner", item)`으로 생성(`Name = "_quad_round"`), + `CornerRadius = UDim.new(0, value)` 설정. +- `PaddingAll = UDim.new(...)` / `PaddingAllOffset = 50` → 동일 패턴, + `UIPadding`(`_quad_padding`). +- `Scale = 1.2` → 동일 패턴, `UIScale`(`_quad_scale`). + +값 모양은 항상 **리터럴 하나**(숫자/UDim) — 테이블도 `__type` 태그도 아님. +실사용 예시(`md/kr/tutorial/7_quadProperty.md`): +```lua +Frame "mainFrame" { + PaddingAllOffset = 50; + ImageFrame { RoundSize = 16; ... }; +} +``` + +**`UIListLayout`/`UIGridLayout`/flex는 이런 전용 숏핸드가 v1에 없었음** — +`ProcessQuadProperty`의 범용 자식 나열 분기(배열 인덱스로 놓인 Instance/ +Class 결과를 자동 mount, 207~213행)로 `UIListLayout{...}`을 그냥 직접 +나열했을 뿐, `List = true` 같은 전용 축약 문법은 레포 전체(PA님 코드 +포함)에서 찾지 못함. **quad-v2도 이 부분은 이미 있는 children-array + +인스턴스 생성 문법으로 그대로 커버됨 — 새로 설계할 것 없음.** 사용자 +기억 중 이 부분은 "전용 숏핸드"가 아니라 "선언형 문법 자체가 원래 +간결하다"는 것과 섞였을 가능성이 큼. + +## 결론 (2026-08-06, 한 차례 오해 후 재정정) + +**RoundSize와 Corner는 서로 다른 이유로 존재했던 별개 기능 — 혼동하지 +말 것**: +- **`RoundSize`(이미지 9-slice 라운드)**: `ImageLabel`/`Button`을 + 이미지 트릭으로 둥글게 보이게 하던 것 — **당시 Roblox에 `UICorner` 같은 + 네이티브 구현체가 없었기 때문에** 존재하던 워크어라운드. 지금은 + `UICorner`가 안정적인 네이티브 Instance라 이 이미지 트릭 자체를 그대로 + 포팅할 이유는 없음(이미지에도 그냥 실제 `UICorner`를 쓰면 됨) — + **RoundSize는 포팅 안 함**. +- **`Corner`/`PaddingAll`/`Scale`(UICorner/UIPadding/UIScale 자동 + 생성)**: 이건 워크어라운드가 아니라 **지금도 유효한 편의 기능** — + **사용자 재확정**: "UIScale 같은 건 여전히 별도의 Instance고 부모 + Frame에 영향을 주는 구조, 숏핸드는 여전히 필요하다". `UICorner`가 + 네이티브가 됐다고 해서 "별도 Instance를 만들어 부모에 Parent해야 + 한다"는 구조적 번거로움 자체가 없어지는 게 아니므로, 이 숏핸드의 + 존재 이유는 여전히 유효함 — **이전 정리("포팅 불필요")는 오해였고 + 정정함, `Corner`/`PaddingAll`/`Scale`은 그대로 포팅 대상.** + +**메커니즘 — 새 아키텍처 개념 불필요**: 이미 있는 pluggable Handler로 +그대로 커버됨. `Corner`/`PaddingAll`/`Scale` 같은 특수 키를 인식하는 +Handler(`isHandlable`이 그 키를 매칭)가 "이름 붙은 자식을 찾거나 만들고 +프로퍼티 세팅"을 `process(inst, k, v)`에 구현 — v1의 하드코딩 if/elseif +대신 정식 핸들러 계약(`isHandlable`/`priority`/`process`/`retract`)을 +따르는 것만 다름. `modifier-plan.md`가 이미 예시로 든 +`Modifier.Rounded(8)`은 이 특수 키를 flatten해서 props에 꽂아넣는 사탕 +문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고 +`Frame { Corner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게 +작동함, `architecture.md`의 `[Attribute "Name"]`류 특수 키와 같은 층위. +자동 생성된 자식은 위 "핵심 설계 방향" 관례대로 `_`/`QUAD_` 접두어 +네이밍(`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 +그대로 재사용). + +**패키지 배치 — `quad-roblox` 코어에 직접 포함, 확정**: "트윈도 인스턴스 +생성/제어를 직접 구현 가능한 걸 하나로 묶어 쉽게 쓰게 합친 것 — 너무 +잘게 쪼개 오버엔지니어링하기보다 확실히 하나로 코어에 넣어도 충분하다, +opt-out할 이유가 별로 없다"는 게 사용자 판단 — **작고 항상 켜져 있어도 +비용이 무시할 만한 편의 기능은 별도 opt-out 패키지로 쪼개지 말고 +`quad-roblox` 코어에 직접 포함한다**는 원칙으로 확정(이미 계획된 Tween +핸들러가 같은 모양이라는 게 근거). 이 원칙은 일반화해서 재사용 가능 — +앞으로 비슷한 "작은 인스턴스 편의 기능"이 제안되면 `quad-roblox-util` +같은 걸 새로 만들지 않고 이 선례를 따르면 됨. + +**중요도**: 낮음("이건 나중에도 쉽게 구현됨" — 사용자) — 지금 M0 우선순위를 +바꿀 이유는 없음, M10(Handlers/Attribute 등) 전후로 다른 세부 Handler와 +함께 구현하면 충분. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 이름 그대로 가져올지(`Corner`/`PaddingAll`/`PaddingAllOffset`/`Scale`) + 재검토할지 — 진행 중인 용어 정리(`CLAUDE.md` "지금 할 일" 2번)에 합류 + 대상. +- `RoundSize`(이미지 라운드)를 완전히 드롭할지, 아니면 이미지 대상에도 + 그냥 실제 `UICorner`를 자동 적용하는 것으로 대체할지 — 후순위. diff --git a/CLAUDE.md b/CLAUDE.md index 2b66eb0..39c7f40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 4. **[백로그] 범용 렌더 디버깅 도구로서의 quad-mock.** 1번의 quad-base 테스트용 mock과는 별개 — 정적 스냅샷을 넘어 Tween mock 같은 동적 동작까지 지원하는 더 큰 스코프의 디버깅 도구(`architecture.md` "테스트 전략" 절 백로그 참고). - 효용성 봐가며 나중에 검토, 지금 당장 설계할 필요 없음. + 효용성 봐가며 나중에 검토, 지금 당장 설계할 필요 없음. **[백로그, 별개]** + 런타임 디버깅 플러그인 `quad-debug`(실물 Instance→코드 위치 역추적, + `research/debug-tooling-plan.md`)도 2026-08-06 세션에서 설계 수렴 — + 채널 실현 가능성(Studio 플러그인↔Play 중 게임 간 BindableEvent/Function + 통신)까지 실측 검증 완료, 세부 API 이름만 남음. 착수 시점은 여전히 + "quad 개발 상당 부분 끝난 뒤"로 사용자가 못박음. 같은 세션에서 파생된 + 문서화 전략 뼈대(`research/documentation-plan.md`, UI 네이밍 컨벤션 + + Store 부작용 게임 시스템 활용 패턴)도 후순위 백로그로 같이 남김. 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). @@ -178,3 +185,93 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 시작**. 용어 정리 제안 진행 중인 점은 위 "지금 할 일" 2번 참고. + +## 2026-08-06 세션 — quad-debug(런타임 디버깅 플러그인) 설계, 실측 검증까지 완료 + +팀원이 "실물 Frame에서 그걸 만든 코드 위치를 역추적하는 Studio 플러그인이 +있으면 좋겠다"는 피드백을 줬고, 사용자가 이걸 `quad-debug`/ +`quad-debug-roblox-plugin`으로 후순위 설계해두자고 판단해 시작된 세션. +착수는 여전히 "quad 개발이 상당 부분 끝난 뒤"로 못박혀 있음(구현 우선순위는 +안 바뀜) — 대신 base 설계(디스패치/Source/DI 생성자) 시점에 훅 확장 +지점만 고려해두면 나중이 훨씬 싸진다는 문제의식으로 지금 미리 설계만 +해둠. 전체 내용은 `research/debug-tooling-plan.md`, 요지만 요약: + +- **설계를 막던 유일한 기술적 불확실성이 실측으로 해소됨**: Roblox Studio + 플러그인과 Play 중인 게임(LocalScript)이 별도 Luau VM/스크립트 + 컨텍스트라 `BindableEvent`/`BindableFunction`이 그 경계를 실제로 + 넘는지가 문서만으로는 안 갈렸음(공식 문서는 언급 없음, DevForum엔 + 실패 사례도 있었음) — 사용자가 테스트용 플러그인/스크립트 + (`plugin-ignoreme.luau`/`game-ignoreme.luau`, 레포 루트, `*-ignoreme*` + 패턴이라 자동 gitignore)를 직접 Studio에서 돌려 **Fire/Connect, + Invoke/OnInvoke 왕복 둘 다 안정적으로 작동함을 확인**. 원리는 "Play + 진입 시 DataModel이 복제되는 게 아니라 script identity/보안 컨텍스트만 + 분리되고, Instance 자체(C++ userdata)는 어느 컨텍스트에서든 같은 + 참조를 가리킨다"는 것 — 사용자가 실측 도중 직접 정리한 설명. +- **채널은 확인됐지만 처음 구상(quad-debug-roblox가 `ReplicatedStorage`에 + Bindable을 자동 생성)은 기각** — 개발자가 의도 안 한 Instance를 게임 + 트리에 주입하는 부작용이 크다는 사용자 지적. 대신 quad 모듈 자신의 + Instance 트리 안에 두고 `CollectionService` 태그로 노출, 플러그인은 + `GetTagged`로 찾음(`GetDescendants` 전체 순회 불필요). +- Roblox Luau의 `debug` 라이브러리엔 `sethook`류가 없어(확인됨) 엔진이 + 공짜로 주는 동적 트레이싱 방법이 없음 — 대신 Fusion `src/External.luau`에 + 이미 있던 "no-op 업밸류를 나중에 실제 구현으로 통째로 교체" 패턴을 + 재사용하기로 함(quad가 이미 쓰는 "base는 인터페이스, 구현은 팩토리가 + 주입" 원칙과 같은 모양이라 새로 발명할 필요 없음). +- React DevTools 아키텍처도 서브에이전트로 조사 — 그대로 못 베끼는 것도 + 있지만(전역 훅 주입은 프로세스 경계 문제로 안 됨), **컴파일타임 소스 + 위치 주입**(Babel처럼 darklua로 흉내낼 후보)과 **얇은 스트림+on-demand + 상세조회** 원칙은 그대로 채택. +- UX 방향은 사용자가 여러 번 직접 정정: "존재하는 State 목록"이 아니라 + "무엇이 무엇에 연결됐는가" 그래프 중심, flash-on-update는 전체 상시 + 적용이 아니라 마운트/언마운트만 상시+개별 프로퍼티 변경은 현재 열어본 + Instance 한정, PropertyChangedSignal 기반 "외부 변경 감지"는 핵심 + 채널이 아니라 보조 신호일 뿐(어디서/왜 바뀌었는지가 quad-debug의 진짜 + 가치라 순수 관찰만으론 부족). **Element Inspector**(화면 클릭으로 UI + 요소 피킹)가 사용자가 실제로 가장 크게 느낀 pain point로 새로 부상 — + Roblox가 Play 중 라이브 UI 편집 도구를 꺼버려서 Explorer만으로 요소 + 찾기가 힘들다는 실사용 불만. +- 부수적으로 파생된 두 가지(quad-debug 범위 밖) 문서화 아이디어 — + UI 네이밍 컨벤션 문서, 스킬/쿨타임/재화 같은 게임 시스템에서 Store의 + 부작용 허용을 깔끔한 패턴으로 쓰는 법 문서 — 를 `research/ + documentation-plan.md`에 뼈대만 분리해서 남김(위 "지금 할 일" 4번). +- **아직 확인 안 된 것 하나**: 사용자가 "이벤트 함수들이 실제 instance를 + 읽을 수 있게 self를 건네받는 게 quad의 관습"이라고 언급했으나 본인도 + "문서화됐는지 모르겠다"고 함 — 어떤 함수(이벤트 핸들러? Compute? + `describe` 훅?)에 정확히 어떤 시그니처로 적용되는지 다음 세션에서 확인 + 필요(`debug-tooling-plan.md` "열린 질문" 참고), 추측성 반영은 안 해둠. + +**같은 세션 후반, 별개 주제 두 개 추가**(quad-debug와 무관, 사용자가 +"적어는 뒀는데 안 줬는건가" 하며 새로 떠올린 것들): +- **Attribute 특수 키 타입 파라미터화** — `[Attribute<> "name"]` + 제네릭 스타일 vs `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리. + 기존 문서 어디에도 없던 신규 논의로 확인(`bind-system-plan.md` + "Attribute 특수 키" 절에 새로 추가) — 소견은 DI 인스턴스 생성 때 이미 + 쓴 "제네릭 하나 + 자주 쓰는 타입만 정적 지름길" 패턴 재사용, 확정은 아님. + Roblox Attribute가 이제 Instance 참조 타입도 지원해서 `ObjectValue` + 없이 Ref 용도로도 쓸 수 있다는 점도 확인 — quad-debug 논의의 "Value + 오브젝트 기각, Attribute 우선" 결정을 보강함. +- **UICorner/UIPadding/UIScale 인라인 편의 키** — 사용자가 v1에서 "Frame + 안에 인라인으로 넣기만 해도 CSS처럼 적용됐다"고 기억한 기능, 서브에이전트로 + v1 소스(`class.lua`) 조사해 실체 확인: `Corner`/`PaddingAll(Offset)`/ + `Scale` 3종(+ 별개 메커니즘인 `RoundSize`)이 실제로 있었음(리터럴 값 하나 + → 이름 붙은 UICorner/UIPadding/UIScale 자식을 찾거나 생성). `UIListLayout`/ + Grid/Flex 전용 숏핸드는 없었음 — 그건 이미 quad-v2에 있는 범용 + children-array 메커니즘으로 충분히 커버되므로 새로 설계할 것 없음. + **한 차례 "지금은 UICorner가 네이티브라 포팅 불필요"로 잘못 정리했다가 + 사용자가 재정정**: `RoundSize`(이미지 9-slice 라운드 트릭)만 UICorner + 없던 시절의 워크어라운드라 포팅 불필요고, `Corner`/`PaddingAll`/`Scale` + 자체는 "UIScale 등이 여전히 별도 Instance라 부모에 붙여야 하는 구조는 + 안 바뀌었다"는 이유로 **여전히 필요한 기능으로 재확정**. `research/ + ui-shorthand-plan.md`에 최종 정리 — 메커니즘은 기존 pluggable Handler로 + 그대로 커버(새 아키텍처 개념 불필요), 패키지 배치는 `quad-roblox` 코어에 + 직접 포함으로 확정(별도 `quad-roblox-util` 불필요 — "트윈도 하나로 묶어 + 코어에 넣은 선례처럼, 작고 opt-in 아닌 건 분리 안 한다"는 사용자 판단). +- **quad-debug 플러그인 UI 구조 확정** — Explorer에서 quad 내부 자동 + 생성물(예: 위 UICorner 숏핸드가 만든 것)을 직접 선택했을 때 플러그인 + 트리에 대응 노드가 없으면 부모로 대신 선택, 있으면(사용자가 직접 + bind한 경우 등, UB 아님) 정확히 그 노드 선택. 내부 자동 생성물은 + `_`/`QUAD_` 접두어로 네이밍(v1 `_quad_round`류 재사용, + `documentation-plan.md` 네이밍 컨벤션과 연결). 플러그인 UI는 세 + 상호작용면(자기 트리 뷰/리프 클릭→상세 패널/실제 Explorer 선택과 연동, + Explorer와 플러그인 트리는 별도 도킹 위젯)으로 구성된다는 것도 사용자 + 질문에 확인 응답 — `debug-tooling-plan.md` "핵심 설계 방향" 9번. diff --git a/ROADMAP.md b/ROADMAP.md index 8b48962..f4c9f03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,5 +112,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 긴장 관계 있음, 재검토 시 그 문서부터 다시 볼 것 - [ ] 범용 렌더 디버깅 도구로서의 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만이면 급하지 않음 From 936e0766b7f8b4da01b259ce0a1cffbc4a6db178 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 17:54:13 +0900 Subject: [PATCH 07/62] =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20self=20?= =?UTF-8?q?=EA=B4=80=EC=8A=B5/Store=20Emit/Ref=20=EC=9D=BC=EB=B0=98?= =?UTF-8?q?=ED=99=94/Observer=20=EB=85=BC=EC=9D=98(2026-08-06=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=20=EC=84=B8=EC=85=98)=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 핸들러 self(Instance) 관습 비채택 확정 (Ref로 충분, Modifier 정적 flatten과의 충돌, quad-debug 추적성, 클로저 비용) - rbvm GC 패턴이 실물 검증됐다는 근거 보강 (lifecycle-pattern.md) - .claude 코퍼스 전체 stale 참조/모순 감사 및 정리 - Store:Emit(key) 확정 (Source 원천 한정, clone 불가 userdata 우선 근거) - :Compute(fn, previous) 확정 (무거운 파생 객체 재사용, full diff 필수) - state:Observer(fn) 확정 (children 배열에 직접 놓는 leaf 값, canExecute 게이팅) - Ref 일반화 확정 (범용 값 박스, 반복 재설정 가능, State와 달리 non-lazy) - CLAUDE.md 핸드오버 갱신 Co-Authored-By: Claude Sonnet 5 --- .claude/base/architecture.md | 3 +- .claude/base/bind-system-plan.md | 204 +++++++++++++++++- .claude/base/comparison-fusion-vide.md | 11 +- .claude/base/lifecycle-pattern.md | 8 + .claude/base/modifier-plan.md | 18 +- .claude/base/store-semantics.md | 46 ++++ .claude/question.md | 16 +- .claude/research/debug-tooling-plan.md | 16 +- .claude/research/documentation-plan.md | 36 +++- .../research/existing-instance-bind-plan.md | 6 + .claude/research/tween-plan.md | 5 +- CLAUDE.md | 105 ++++++++- 12 files changed, 440 insertions(+), 34 deletions(-) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index dd45855..791b021 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -53,7 +53,8 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 갱신됨 — 위 5번 항목의 6차 추가분 참고: 스타일시트는 적용 위치 제약과 스크립팅 난이도 때문에 오히려 안 쓰기로 하고 modifier kit으로 대체함.) - **2026-08-04 세션: modifier 메커니즘 전체 확정, 상세는 - `research/modifier-plan.md`로 분리.** 요지만: 런타임 pluggable 핸들러가 + `base/modifier-plan.md`로 분리.**[정정: `research/`에서 `base/`로 + 승격됨] 요지만: 런타임 pluggable 핸들러가 아니라 디스패치 이전에 정적으로 flatten되는 값(핸들러 레지스트리 미참여, CSS cascade 문제 회피). Merge 우선순위는 "배열 순서상 나중 modifier가 우선"과 "인라인 키는 modifier보다 무조건 우선"이라는 독립된 두 규칙(Lua diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index c70558f..3b5ccda 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -167,6 +167,115 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 - **콜백 호출 시점 확정**: 생성 직후(construction 시점)와 트리 마운트 후(Parent 세팅 완료) 둘 다 지원 — 옵션으로 선택(`CreatedRef(fn, {phase="created"| "mounted"})`류, 정확한 API 이름은 구현 단계에서 확정). +- **왜 값이 아니라 콜백인가**: quad는 React처럼 렌더 함수가 계속 + 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 + "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, `useEffect` + 의존성 배열 같은 것도 없음. 즉 값이 채워지는 시점을 외부에서 알아낼 + 방법이 콜백(또는 폴링, 채택 안 함 — `lifecycle-pattern.md`에서 폴링 + 방식은 이미 기각된 패턴) 말고 없음. 값 자체를 나중에 다루고 싶으면 + 콜백 안에서 원하는 곳(외부 변수, `self._button` 같은 필드, Store 등)에 + 직접 대입해 캡쳐하면 됨 — `component-composition-plan.md` 31행 예제 + 참고. 즉 "값으로도 얻어진다"는 요구는 별도 API가 아니라 콜백이 이미 + 충족함. + +### Ref 일반화 — 엔진 instance 전용이 아니라 범용 값 박스 (2026-08-06 후속 세션) + +**결정**: Ref는 "quad가 만든 instance를 얻는 통로"로 좁게 남지 않고, +**아무 사용자 값이나 담을 수 있는 범용 "채워지길 기다리는 값 박스"**로 +확장한다. 위 "코루틴 기반 대기 지원 여부는 미정"이었던 항목은 이걸로 +해소됨(더 이상 열린 질문 아님). + +- **object-ref/function-ref로 나누지 않음.** React의 `useRef`가 DOM + 노드든 임의의 사용자 값이든(함수 포함, `ref.current?.()`로 호출하는 + imperative-handle 패턴 포함) 같은 API로 다루는 것과 동일한 선례 — + 두 개념으로 쪼개면 사용자가 "이번엔 어느 쪽을 써야 하나" 매번 + 판단해야 해서 나쁨. 엔진 instance도 그냥 "사용자 값의 한 종류"일 뿐. +- **구체 유스케이스**: 자식 컴포넌트가 비싸고 온디맨드로만 필요한 계산 + (예: 클릭 위치 기준 컨텍스트 메뉴를 그리기 위한 clip bounds 계산)을 + 부모에 노출하고 싶을 때, 매 변경마다 push하는 대신 부모가 필요할 때만 + `ref.Value?.()`처럼 호출하는 함수를 Ref에 담아 넘기는 패턴 — React의 + imperative handle과 동일한 이유(비싼 연산이라 온디맨드가 맞음, 값이 + 최신인지 아닌지도 애매해짐). +- **API 모양**: `.Value`(get/set) + `:Wait()`(coroutine 컨텍스트에서 + 사용 — 렌더 함수 바디 안에서 `return` 위에 바로 못 씀, 그래서 콜백도 + 같이 필요) + 콜백 등록(복수 허용, 이미 채워져 있으면 등록 즉시 그 + 값으로 1회 호출 — nil/미설정 상태여도 그 상태 그대로 호출. React의 + `useEffect`가 매번 `.current` 존재 여부부터 체크하는 것과 같은 이유, + Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 + "이미 채워졌는지" 확인이 항상 필요함). `:Wait()`의 대기자 리스트와 + 콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 nil 처리, + Luau의 일반화 for는 성긴 배열도 잘 순회함). +- **`CreatedRef`와의 관계**: 둘은 상충하지 않음 — 이 절의 Ref가 범용 + 프리미티브, `CreatedRef(fn, {phase=...})`는 그 위에 얹힌 "children + 배열에 넣으면 dispatch가 자동으로 채워주는" 특수 편의 패턴(quad가 + 만든 instance에 한정된 경우). +- **해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정.** React에서도 + 자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스 + 호버/무브 시 `current` 확인 후 라벨 위치를 결정하는 라벨 컨테이너 + 하나를 두고 라벨 내용만 스왑해가며 Ref를 재사용하는 패턴. 이런 고급 + 패턴은 조심할 게 많지만 그건 라이브러리가 아니라 사용자가 신경 쓸 + 몫. **따라서 콜백은 "발화 후 소진"이 아니라 매 `:Set()`마다 다시 + 불림** — 소진되는 건 `:Wait()`가 만드는 개별 대기자(coroutine 재개는 + 본질적으로 1회성)뿐, 콜백 리스트 자체는 유지됨. +- **⚠️ Ref는 의도적으로 lazy가 아니고 `:Compute` 파생을 지원하지 않음 + — State와의 이 차이가 중요함.** (예전엔 Store가 Ref와 비슷한 것도 + 겸해서 지원한 적이 있었는데, State의 lazy 재계산 모델과 Ref의 즉시 + get/set 모델이 섞여서 좋지 않았음 — 그 경험에서 나온 의도적 분리.) + Ref는 그냥 "지금 뭐가 들어있나/누가 채워주길 기다리나"만 다루는 즉시 + 값 박스이고, 파생값이 필요하면 Store/State(`:With`+`:Compute`)를 쓸 것 + — 둘을 섞으려 하지 말 것. +- **용어 정리 합류 대상**: Ref의 정의 자체가 "instance를 얻는 것"에서 + "범용 값 박스"로 넓어졌으므로, 진행 중인 용어 정리(`question.md` 1번) + 때 이름이 여전히 맞는지 같이 재검토할 것. + +## 이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06) + +**결정**: v1의 `function(self, ...)` 관습(`self`/`this`로 이벤트 대상 +Instance를 넘겨주는 것, `.claude/base/quad-v1-architecture.md` 참고 — +실제로 `event.lua`의 `Bind`가 `func(self or this, ...)`로 넘겨줌)은 +**채택하지 않는다.** quad-roblox의 이벤트 핸들러는 엔진이 네이티브로 +주는 이벤트 인자만 받는다(React의 `onXxx`가 DOM 노드가 아니라 +SyntheticEvent만 주는 것과 같은 모양). + +**근거**: +1. **Ref가 이미 이 자리를 채움.** "생성 직후/마운트 후 ref 채우기"가 되는 + 순간 Instance 접근이 필요하면 클로저 캡처로 해결됨(위 Ref 절) — self는 + 그와 중복되는 두 번째 채널일 뿐이고, 두 채널이 있으면 "어느 쪽이 + authoritative냐"는 질문이 항상 따라붙음. +2. **thin wrapper를 제공하면 엔지니어링 구조 자체가 바뀜.** self로 얻는 + 값이 mutable한 재바인드 가능 wrapper라면, 그건 Modifier의 정적 + flatten(`base/modifier-plan.md`)과 항상 경쟁하는 두 번째 쓰기 경로가 + 생긴다는 뜻 — flatten된 뒤엔 wrapper 쪽에서 "이전 modifier가 뭐였는지" + 재구성할 방법이 없음. Modifier 핸들러가 KV 매치 기반이라는 걸 감안하면, + wrapper 값을 처리하려면 핸들러가 "이게 flatten된 정적 값이냐, 아니면 + 언제든 바뀔 수 있는 wrapper냐"를 매번 분기해야 함 — 오버엔지니어링이고 + hot path(매 `process` 호출)에 분기 비용이 붙음. 반대로 raw Instance를 + 그대로 주는 선택지도 있지만, 그러면 quad가 스스로 지양하는 "quad가 + 모르는 직접 mutate 경로"를 공식 API로 만들어주는 셈이라 (3)과 충돌. +3. **디버깅 관점에서 더 결정적.** quad-debug의 가치 제안이 "무엇이 + 무엇에 연결됐는가"를 선언된 반응형 그래프로 추적하는 것인데 + (`research/debug-tooling-plan.md`), self로 얻은 Instance를 이벤트 + 핸들러 안에서 직접 mutate하는 경로는 그 그래프 밖 — `base/ + purity-and-effects-plan.md`의 "재사용 가능한 컴포넌트는 store만 + 파라미터로 받아야 한다"는 이식성 원칙과도 같은 결. +4. **성능/GC**: self를 넘겨주려면 원본 콜백을 클로저로 한 번 더 감싸야 + 함(`event:Connect(function(...) func(self, ...) end)`) — Connect마다 + 불필요한 클로저 할당 비용이 들고, 최적화에도 GC 흐름에도 좋을 게 + 없음. self가 없으면 사용자가 준 함수를 그대로 `:Connect`에 넘기면 + 충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로 + (`base/lifecycle-pattern.md`, rbvm 선례 — GC-native), Destroy되면 + 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 + 불필요. 동적으로 Connect/Disconnect를 반복해야 하는 최적화 케이스가 + 실제로 생기면, 그건 Ref로 얻은 Instance를 갖고 사용자 코드가 직접 + 처리하면 됨(사용자가 실사용 케이스로 확인한 바로도 이런 니즈는 + 드묾 — 드문 케이스를 위해 구조 전체를 복잡하게 만들 이유 없음). + +**일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — +"엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 +호출해줘도 무방하다"는 것. 다만 이벤트 등록 자체가 quad-roblox에만 +있는 개념이라(다른 백엔드는 이벤트 모델이 다를 수 있음) 이건 base +문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 +참고할 만한 템플릿 정도로만 취급. ## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양 @@ -185,6 +294,97 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 lazy State 핸들로 통일, 아래 "Store/State/Source 온톨로지" 절의 "`:With`/ `:Compute`" 부분 참고). +### `:Compute(fn)`의 선택적 두 번째 인자 — `previous` (무거운 파생 객체 재사용, 2026-08-06) + +**배경**: `:Compute`의 결과가 그 자체로 무겁고 재생성 비용이 큰 엔진 +객체일 수 있음(예: 큰 로케일 테이블을 Roblox `LocalizationTable` +Instance로 변환하는 경우 — `LocalizationTable`은 `Set`/`Get`/`List`로 +부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로 +재사용해 필드만 patch하고 싶을 때를 위해, `fn(value, previous)` 형태로 +**직전에 이 Compute 함수가 반환했던 값**을 두 번째 인자로 받을 수 있게 +한다. + +- **opt-in**: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 — + 비용 0. 대부분의 Compute는 이걸 쓸 필요 없음. +- **`previous`는 "바로 직전 버전"이 보장되지 않음.** lazy pull 모델이라 + 중간에 여러 번 무효화됐어도 실제로 관측(`Get()`) 안 됐으면 재계산 + 자체가 안 일어남 — 그래서 `previous`는 몇 세대 전 값인지 알 수 없음. + **따라서 `previous`를 다루는 로직은 반드시 "현재 입력 전체 대 이전 + 결과 전체"의 full diff여야 하고, "정확히 한 단계 전"이라고 가정하는 + incremental delta 로직을 짜면 안 됨.** 이건 React 자체의 reconciler가 + 하는 것과 같은 모양(old tree/new tree 전체 비교 후 실제 host 객체에 + 패치 적용)이라 새로 발명하는 패턴은 아님. +- 최종 소비처가 patch된 값을 다시 한번 Set/Parent하게 되는 경우가 + 있어도(레퍼런스는 같은데 다시 대입) 대체로 치명적이지 않음(Roblox + 프로퍼티 재대입은 저렴/멱등인 경우가 대부분) — 문서화만 해두면 충분. + +**⚠️ 이 패턴을 쓸 때 반드시 같이 지켜야 하는 것 — "확정(관측)되기 전엔 +연산이 없다".** `previous`를 mutate하는 로직은 Compute 함수 **본문 +안**에 있으므로, 그 함수가 재실행되지 않으면(=아무도 다시 `Get()`하지 +않으면) mutation 코드 자체가 아예 실행되지 않는다 — 단순히 "가끔 +stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. 이 패턴으로 +만든 State는 반드시 다음 중 하나로 계속 능동적으로 관측되어야 함: +1. quad의 정상적인 선언적 prop 바인딩 경로(`[Property "X"] = someState` + 류)에 실제로 물려있어서, dispatch 엔진이 무효화 시 자동으로 + 재`Get()`하게 되어 있거나, +2. 아래 "Observer" 절의 `state:Observer(fn)` + 콜백 안에서 명시적 + `Get()` 호출 + 그 결과를 children 배열에 넣어 라이프사이클에 + 묶어두기. +"Ref로 한 번 얻어서 수동으로 Parent만 하고 끝"처럼 능동적 관측 경로가 +안 남아있으면, 이 최적화는 그냥 조용히 작동을 멈춘다. + +### `state:Observer(fn)` — 값을 안 실어주는 구독, children 배열에 직접 놓는 leaf 값 + +**결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` +래퍼 타입은 안 만듦 — `state:Observer(fn)`가 반환하는 값 자체가 이미 +"children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. +`CreatedRef`와 완전히 같은 층위: + +```lua +local observer = state:Observer(function() + state.value +end) + +Frame { + observer, +} +``` + +이러면 `observer`는 `Frame`이 살아있는 동안만 유지되고, `Frame`이 +retract/Destroy되면 자동으로 정리됨. + +- **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은 + 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(아래 + "Store/State/Source 온톨로지" 절)이 그대로 적용됨: `fn`은 "뭔가 + 바뀌었으니 다시 확인하라"는 신호만 받고 새 값 자체는 안 받음 — + 위 예시처럼 `fn` 본문에서 `state.value`/`Get()`을 명시적으로 다시 + 읽어야 함. 자동으로 안 해주는 이유: 재계산이 진짜 필요한지가 다른 + `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 + `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` + 호출 여부를 작성자가 직접 결정하게 열어둔 것. +- **base가 제공하는 것은 `isObserver`류 타입 판별자 하나** — children + 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 + `CreatedRef`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 + 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array + 참가자 패턴의 반복. +- **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과 + 동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님) + — 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op. +- **구현 노트(사용자 제안, 확정된 아키텍처는 아니고 구현 시 참고)**: + 살아있는 Observer 집합을 Observer 값 내부 필드로 안 두고, 외부에 + weak table(`{[observer] = true}`, `__mode = "k"`)로 인덱싱하는 방식을 + 선호 — 포인터 해싱 비용만 들고 값 자체엔 부작용 없음. rbvm의 + `getNamespaceOf`류가 비슷한 외부 weak-table 인덱싱을 씀 + (`base/lifecycle-pattern.md` 참고). +- **인자 없는 `state:Observer()` — "항상 관측" 유틸.** `fn`을 생략하면 + 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 + 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자" + 절의 캐비엇("능동적 관측 경로가 안 남아있으면 mutate 로직이 조용히 + 멈춘다")을 만족시키는 가장 단순한 도구 — 별道 콜백 로직 없이 그냥 + 이 State가 계속 재계산되게만 강제하고 싶을 때 씀 — 별도 콜백 로직 없이 + 이 용도로만 쓰고 싶을 때. 문서화만 확실히 하면 별문제 없음(사용자 + 판단). + ## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 **배경**: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. @@ -353,7 +553,9 @@ Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실 방식이면, 나중에 GC만으로 정말 부족한 케이스가 생겨도 그 connection을 얻어 `disconnect()`하는 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인 — 지금 마일스톤에서는 필요 없어서 안 함(사용자: "필요하다면 dispose 핸들러를 - 만들어주는 것도 가능한 디자인, 다만 지금까지 요구가 없었음"). + 만들어주는 것도 가능한 디자인, 다만 지금까지 요구가 없었음"). (rbvm의 + GC-native 패턴이 실물에서 검증됐다는 근거는 `base/lifecycle-pattern.md` 상단 + 참고 메모 참고.) ## quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지 diff --git a/.claude/base/comparison-fusion-vide.md b/.claude/base/comparison-fusion-vide.md index bbac9a6..613bff5 100644 --- a/.claude/base/comparison-fusion-vide.md +++ b/.claude/base/comparison-fusion-vide.md @@ -56,8 +56,13 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 인용되는 원본 비교 자 ## 추가로 기록해둘 것 - Vide의 암묵적(ambient stack) 의존성 추적 vs Fusion의 명시적 `use()` 축은 - push/pull 축과 독립적인 별개 결정 — quad는 아직 미정(`base/bind-system-plan.md` - 열린 질문 참고). Fusion의 명시적 `use()`는 `checkLifetime` 같은 bind-time - 체크를 가능하게 하는 부수 효과가 있음. + push/pull 축과 독립적인 별개 결정. **[정정] 리서치 당시(2026-08-03 이전)엔 + "quad는 아직 미정"이었으나, `base/bind-system-plan.md`의 "여러 Store 값을 + 묶어 파생값 만들기 — `:With` + `:Compute`" 절에서 이미 명시적 모델로 + 확정됨** — Vide식 암묵적 ambient stack 추적은 "함수 실행 중과 끝 사이를 + 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔하지 않다는 이유로 기각, + `:With(...)` + `:Compute(fn)`(클로저로 직접 읽는 명시적 방식)를 채택. + Fusion의 명시적 `use()`는 `checkLifetime` 같은 bind-time 체크를 가능하게 + 하는 부수 효과가 있음. - 두 라이브러리 다 mount 시 단일 소유권 가드가 없다는 것 자체가 quad Slot의 차별점이라는 근거로 재사용 가능. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 962a2ab..6a5d18d 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -3,6 +3,14 @@ **상태**: 결정됨(base) — quad-v2가 채택할 라이프사이클/정리(`retract`) 전략의 원본. 완료 개념 없음, 구현하면서 세부 조정 있을 수 있음. +**참고(2026-08-06)**: rbvm은 사용자가 직접 실행해 실제로 잘 동작하는 것을 확인한 +코드베이스(프로덕션에서 검증됨) — 사람이 짠 코드라 무결성이 100% 보장되는 건 +아니지만(실제로 아래 5번 항목에서 `Connected` 체크 방향 반전 버그, `__mode` 오타 +버그가 발견됨), 다른 리서치 참고 레포보다 "실물로 돌아간다"는 근거가 확실한 +비교 대상. 단 이건 참고용 비교 대상일 뿐 quad-v2가 반드시 따라야 할 규범은 +아님 — 실제 채택 여부는 이 문서 각 절의 확정 내용(특히 아래 "확정" 두 절)을 +따를 것, rbvm 코드를 그대로 베끼라는 뜻이 아님. + ## 배경 `raw-userinput.md`(이하 사용자 원 메모): "레지스터의 실행은 내 생각엔 그냥 Destroy diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 82274b7..a8c2672 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -3,8 +3,9 @@ **상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable 값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. 남은 건 getter 정확한 이름뿐(구현 단계). Modifier가 컴포넌트 경계를 어떻게 -통과하는지(다중 루트, 상속 방식)는 별개 문제로 -`research/component-composition-plan.md`의 열린 질문에 남음 — 이 문서는 +통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로 +**[정정] `research/component-composition-plan.md`는 2026-08-04 세션에 수렴 +완료돼 `base/component-composition-plan.md`로 승격됨 — 이 문서는 "Modifier 값 자체가 어떻게 동작하는가"만 다룸. ## 문제 @@ -26,6 +27,12 @@ pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드 변경은 전체 교체, 부분 오버레이 없음"(`base/architecture.md` 3번) 원칙과 충돌함. +관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시 +해야 하는지/clone이 필요한지는 별개 미정 문제로 +`research/existing-instance-bind-plan.md`의 "Default 값과 얽히는 문제" 절 +참고 — 여기서 다루는 건 컴포지션 타임의 modifier 값 자체 flatten이라 층위가 +다름. + ### 2. Merge 우선순위: 배열 순서와 인라인은 독립된 두 규칙 `Frame { modifier1, modifier2, Name = ... }` 평탄화 시: @@ -157,6 +164,7 @@ State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어 - Getter 정확한 이름/모양(`:Get(key)` vs dot-access 겸용) — 후순위, 구현 단계에서 다른 세부 API 이름들과 같이 확정 가능. -- Modifier가 컴포넌트 경계를 어떻게 통과하는지(다중 루트, 상속 방식)는 - `research/component-composition-plan.md`에서 계속 다룸 — 이 문서가 다루는 - "값 자체의 동작"과는 별개 문제. +- Modifier가 컴포넌트 경계를 어떻게 통과하는지는 **[정정] 이미 + `base/component-composition-plan.md`에서 해소됨**(named parameter로 + 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 + 다루는 "값 자체의 동작"과는 별개 문제였다는 점만 참고로 남김. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index c48e758..cd1a2f8 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -88,6 +88,52 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 인체공학(사용자가 좋아하는 부분)은 유지하되 내부 구현(체이닝이 아니라 팩토리 함수)만 바꾼다. +## Store 값을 직접 mutate한 뒤 전파 — `:Emit(key)` (2026-08-06 후속 세션) + +**결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서 +mutate한 뒤, `Store:Emit(key)`로 그 key의 무효화 신호만 별도로 쏘는 +것을 **Source 원천(store가 직접 들고 있는 값)에 한해 허용**한다. + +**존재 이유(우선순위순)**: +1. **clone이 아예 불가능한 값이 있음.** userdata나 외부 라이브러리 + 객체(엔진 Instance 등)는 `table.clone`으로 새 값을 만들 수 없음 — + 이런 값은 "새 값을 만들어 Set"이라는 대안 자체가 없으므로, in-place + mutation + `Emit`이 변경을 전파하는 유일한 수단. +2. Lua의 불변 업데이트가 verbose함(JS의 `{...t, x=1}` 같은 문법이 없어 + `table.clone` 후 필드 덮어쓰기 + 재대입 필요) — 이걸 줄여주는 부차적 + 이득도 있지만, 이게 주된 이유는 아님(1번이 진짜 이유). + +**왜 새 구멍이 아닌가**: `Get()`은 원래도 라이브 테이블 레퍼런스를 +돌려주므로, 그 레퍼런스를 mutate하는 것 자체는 `Emit` 유무와 무관하게 +Lua에서 항상 가능한 일. `Emit`이 없으면 그 mutation은 "조용히 반영 안 +되는"(dependent가 재계산 안 됨, UI가 stale한 채 멈춤) 상태로 남을 뿐이라 +오히려 `Emit` 없는 쪽이 더 나쁜 버그 클래스 — `Emit`은 이미 가능한 +mutation에 정식 신호를 붙여주는 것뿐. + +**남는 캐비엇(문서에 반드시 명시)**: `Get()`으로 이전에 그 테이블을 +읽어서 어딘가(로컬 변수, 다른 코드가 들고 있는 참조)에 캐시해둔 게 +있다면, mutation 순간 그것도 같이 바뀐다 — 새 테이블이 아니라 같은 +레퍼런스라서. **`Get()` 결과를 나중 비교(`==`)나 diff 캐시 용도로 들고 +있으면 안 됨 — 항상 다시 `Get()`할 것.** + +**하드 경계 — Source 원천에만 허용, 중간/파생 State에는 없음.** `:With`/ +`:Compute`로 만들어진 파생 State에는 `Emit`이라는 개념 자체가 없다 — +허용하면 "이 State의 현재 값이 뭘 근거로 계산됐는가"를 아무도 설명할 수 +없게 되어(quad-debug가 추적하려는 "무엇이 무엇을 계산했는가" 그래프가 +깨짐) 디버깅이 사실상 불가능해짐. State의 값은 항상 "선언된 Compute +함수를 실제로 실행한 결과"여야 한다는 불변식이 깨지면 안 됨. 무거운 +파생 객체를 재사용하고 싶은 경우(Compute의 결과 자체가 무거운 userdata인 +경우)를 위한 별도 메커니즘은 `base/bind-system-plan.md`의 "`:Compute(fn)`의 +선택적 두 번째 인자 — `previous`" 절 참고 — 이건 `Emit`과 다른 메커니즘. + +**따름정리 — `Store`의 `T`는 Modifier가 될 수 없음.** Modifier는 +정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고 +(`base/modifier-plan.md`), `State`가 UB로 확정된 것도 같은 +이유(`modifier-plan.md` 7번) — Store/State/dispatch 경로엔 애초에 +Modifier용 processor가 없음. 위 "하드 경계"와 같은 이유로, `Emit`이 +Modifier의 정적 flatten과 충돌할 걱정 자체가 성립하지 않음(둘이 만날 +지점이 없음). + ## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 `useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 diff --git a/.claude/question.md b/.claude/question.md index 2e1d142..ef65b47 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -35,6 +35,11 @@ - **`CreatedRef`/`canExecute`(3순위, 사소함)**: `CreatedRef`는 과거분사형이라 생성자처럼 안 읽힘. `canExecute`는 실제로 "이 핸들이 아직 살아있나" 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. +- **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 + "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 + 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ + 그냥 "plug" 중 뭘로 할지 아직 안 정함(개념 자체는 확정). 이 문서가 자체적으로 + "question.md에도 취합"이라고 표시해뒀던 항목이 누락돼 있어 이번에 추가. - **이미 지나간 사례로 참고**: `register`(v1) → `State`(v2) 리네임은 "모호함"은 풀었지만 "다른 뜻으로 이미 쓰이는 단어"라는 새 문제를 만든 셈 — 이번 정리에서 같은 패턴을 조심할 것. @@ -55,11 +60,12 @@ - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 - 다 해소됨, 남은 건 세부 API 이름과 "이벤트 함수가 self로 instance를 - 읽는 게 quad 관습"이라는 언급 확인(그 문서 "열린 질문" 절 참고)뿐. - 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정한 건 - 여전함 — base 설계(M2 Dispatch/M3 Source/M5 DI 생성자) 시점에 훅 확장 - 지점만 고려해두면 됨. + 다 해소됨, 남은 건 세부 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`(뼈대만). 정식 백로그 항목으로 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index c3dd794..7425616 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -491,13 +491,19 @@ Tween mock 등 동적 동작 포함")와 목적이 다름: 기술적 실현 가능성은 전부 해소됨(위 핸드오버 요약) — 아래는 우선순위별로만 분류, 다음 세션 진행을 막는 항목 없음. -**확인 필요(다음 세션에서 사용자에게 물어볼 것)** +**해소됨 (2026-08-06 후속 세션)** - "이벤트 함수들이 실제 instance를 읽을 수 있게 self를 건네받는 게 quad의 - 관습"이라는 언급 — 사용자가 이번 논의 중 언급했으나 본인도 "문서화됐는지 - 모르겠다"고 함. 정확히 어떤 함수(이벤트 핸들러? Compute? `describe` 훅?)에 - 어떤 시그니처로 적용되는 관습인지 확인 후 해당 base 문서 - (`bind-system-plan.md` 등)에 반영 필요 — 지금은 추측성 반영 안 함. + 관습"이라는 언급 — 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 이름 (후순위, 구현 착수 시점에 자연히 정리)** diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md index 8778ebc..29e643e 100644 --- a/.claude/research/documentation-plan.md +++ b/.claude/research/documentation-plan.md @@ -4,7 +4,8 @@ (2026-08-06) 도중 파생된 두 아이디어를 별도로 트래킹할 가치가 있다고 판단해 뼈대만 기록해둠. 세부 설계는 없음, 착수 시점도 미정 — 사용자가 "간단히 plan 작성은 하는 게 맞을 듯, 아주 뼈대만 있는 것이라 할지라도 -적어줄 필요는 있겠다"고 판단해서 만든 문서. +적어줄 필요는 있겠다"고 판단해서 만든 문서. 같은 날 후속 세션에서 +이벤트 핸들링 self 관습 논의가 확정되며 3번 항목이 추가됨. ## 1. UI 요소 네이밍 컨벤션 문서 @@ -18,8 +19,12 @@ Roblox가 Play 중 라이브 UI 편집 도구를 꺼버려서 실제 화면의 U **뼈대(아직 설계 아님, 물음표만)**: - 어떤 단위에 이름을 붙이게 유도할까 — 모든 Frame? 컴포넌트 루트만? - 자동 이름 부여 옵션이 있으면 좋을까(예: 컴포넌트 함수 이름을 기본 - `Name`으로 쓰는 관례 — 있으면 편하지만 매직에 가까워 `architecture.md` - 2번의 "마법 안 쓴다" 원칙과 긴장 있을 수 있음, 검토 필요). + `Name`으로 쓰는 관례 — 있으면 편하지만 매직에 가까워 "마법 안 쓴다" 원칙과 + 긴장 있을 수 있음, 검토 필요).[정정: 인용 위치 정정 — + `architecture.md` 2번(함수형 vs OOP 논의)이 아니라 + `base/component-composition-plan.md` §1("컴포넌트 = 그냥 함수, '자기 + store 자동 소유' 매직은 폐기")의 **사용자 확정** "마법 안쓴다 그것도 + 동의함" 발언이 근거임] - 어디에 문서화할까 — 퀵스타트 튜토리얼? 별도 스타일 가이드? 린트 규칙으로 강제할까(과한 선택지, 참고만)? @@ -50,9 +55,32 @@ UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같 문서에 합칠지 분리할지는 미정. - 안티패턴 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 한정 설명으로 둘지, 다른 백엔드 구현자를 + 위한 일반 가이드로도 남길지는 미정. +- 어느 문서에 넣을지 — 퀵스타트? 위 1번 네이밍 컨벤션 문서와 마찬가지로 + 아직 미정. + ## 다음 단계 -둘 다 지금 당장 설계할 필요는 없음(`CLAUDE.md` "지금 할 일" 1번 — +셋 다 지금 당장 설계할 필요는 없음(`CLAUDE.md` "지금 할 일" 1번 — 구현 착수가 최우선). 사용자 판단이 필요한 것: - 이걸 `.claude/question.md`/`CLAUDE.md`에 정식 백로그 항목으로 올릴지, 아니면 이 파일 하나로 충분한지. diff --git a/.claude/research/existing-instance-bind-plan.md b/.claude/research/existing-instance-bind-plan.md index d0b45f8..c824584 100644 --- a/.claude/research/existing-instance-bind-plan.md +++ b/.claude/research/existing-instance-bind-plan.md @@ -27,6 +27,12 @@ Default를 넣으면 더 어려워짐 — Default로 쓰다가 실제 쓰인 값 복사(clone) 모드를 켜야 하나 — 근데 그러면 너무 복잡해질 것 같다는 우려까지만 기록. 결론 없음. +관련: Modifier flatten 자체의 clone 정책(변환마다 `table.clone`, immutable +확정)은 이미 `base/modifier-plan.md`의 "3. Immutable 값 + clone 기반 체이닝" +절에서 다뤄짐 — 단 그건 "Modifier 값 자체의 clone"이고, 여기서 미정인 건 +"이미 마운트된 Instance에 대한 재바인드 시점의 Default→실값 flatten"이라 층위가 +다름, 새로 설계하지 않고 참고 링크만. + ## 사용자 확인 결과: 진짜로 모르겠음 — 열린 가능성으로 유지 **사용자 확인 완료, 그러나 결론은 "미정 유지".** 실제로 이 기능을 원한다고 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 591a512..cc34469 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -6,8 +6,9 @@ 정도. 원본: `.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은 항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이 -반응 그래프 안에 있는 설계는 명시적 반면교사 — `base/comparison-fusion-vide.md` -"Animation(Tween/Spring) integration — 경고 신호" 절 참고. +반응 그래프 안에 있는 설계는 명시적 반면교사 — [정정: 절 제목이 실제와 +달랐음] `base/comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿 +("Tween/Spring이 State그래프 안의 1급 노드") 참고. ## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 diff --git a/CLAUDE.md b/CLAUDE.md index 39c7f40..a28b2c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,9 +120,10 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `research/debug-tooling-plan.md`)도 2026-08-06 세션에서 설계 수렴 — 채널 실현 가능성(Studio 플러그인↔Play 중 게임 간 BindableEvent/Function 통신)까지 실측 검증 완료, 세부 API 이름만 남음. 착수 시점은 여전히 - "quad 개발 상당 부분 끝난 뒤"로 사용자가 못박음. 같은 세션에서 파생된 - 문서화 전략 뼈대(`research/documentation-plan.md`, UI 네이밍 컨벤션 + - Store 부작용 게임 시스템 활용 패턴)도 후순위 백로그로 같이 남김. + "quad 개발 상당 부분 끝난 뒤"로 사용자가 못박음. 같은 날 파생된 문서화 + 전략 뼈대(`research/documentation-plan.md` — UI 네이밍 컨벤션, Store + 부작용 게임 시스템 활용 패턴, 권장 이벤트 핸들링 패턴 3종)도 후순위 + 백로그로 같이 남김. 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). @@ -234,11 +235,8 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 UI 네이밍 컨벤션 문서, 스킬/쿨타임/재화 같은 게임 시스템에서 Store의 부작용 허용을 깔끔한 패턴으로 쓰는 법 문서 — 를 `research/ documentation-plan.md`에 뼈대만 분리해서 남김(위 "지금 할 일" 4번). -- **아직 확인 안 된 것 하나**: 사용자가 "이벤트 함수들이 실제 instance를 - 읽을 수 있게 self를 건네받는 게 quad의 관습"이라고 언급했으나 본인도 - "문서화됐는지 모르겠다"고 함 — 어떤 함수(이벤트 핸들러? Compute? - `describe` 훅?)에 정확히 어떤 시그니처로 적용되는지 다음 세션에서 확인 - 필요(`debug-tooling-plan.md` "열린 질문" 참고), 추측성 반영은 안 해둠. +- **이벤트 self 관습 확인 필요했던 항목 — 같은 날 후속 세션에서 해소됨.** + 아래 "2026-08-06 후속 세션" 절 참고. **같은 세션 후반, 별개 주제 두 개 추가**(quad-debug와 무관, 사용자가 "적어는 뒀는데 안 줬는건가" 하며 새로 떠올린 것들): @@ -275,3 +273,94 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 상호작용면(자기 트리 뷰/리프 클릭→상세 패널/실제 Explorer 선택과 연동, Explorer와 플러그인 트리는 별도 도킹 위젯)으로 구성된다는 것도 사용자 질문에 확인 응답 — `debug-tooling-plan.md` "핵심 설계 방향" 9번. + +## 2026-08-06 후속 세션 — 이벤트 self 관습 결정, rbvm GC 참고, 문서 코퍼스 정리 + +같은 날 이어진 세션에서 세 가지를 처리함. **다음 세션이 새로 알아야 할 것은 +없음** — 아래 전부 `base/`/`research/`/`question.md`에 실제로 반영 완료. + +**1. 이벤트 핸들러 self(Instance) 관습 — 채택하지 않기로 확정.** 위 절에서 +"확인 필요"로 남겨뒀던 것의 결론: v1의 `func(self or this, ...)` 관습은 +실존함을 확인했지만(`.claude/initreq/quad/src/event.lua` 82행, 튜토리얼 +문서화까지 있음), quad 재설계에서는 채택하지 않음. 근거 네 가지 — +(1) Ref가 이미 "생성 직후/마운트 후 Instance 접근"을 콜백으로 커버해서 +중복 채널이 됨, (2) self로 재바인드 가능한 thin wrapper를 준다면 Modifier의 +정적 flatten과 경쟁하는 두 번째 쓰기 경로가 생겨 KV 핸들러가 매번 +"flatten된 값이냐 wrapper냐"를 분기해야 하는 오버엔지니어링, (3) +quad-debug가 추적하는 반응형 그래프 밖의 mutate 경로가 공식 API로 +생기는 셈이라 `purity-and-effects-plan.md`의 이식성 원칙과 충돌, (4) +self를 넘기려면 원본 콜백을 클로저로 한 번 더 감싸야 해서 불필요한 할당 +비용 — quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있어 Destroy 시 +Connection도 자연히 정리되므로(`lifecycle-pattern.md`, GC-native) 감쌀 +이유가 없음. 상세 결정문은 `base/bind-system-plan.md`의 "이벤트 핸들러는 +self(Instance)를 받지 않는다" 절. `research/debug-tooling-plan.md`/ +`.claude/question.md`의 관련 항목은 "해소됨"으로 갱신 완료, 이 결정을 +설명하는 문서화 숙제("왜 thin wrapper를 안 주는가", "권장 이벤트 핸들링 +패턴")는 `research/documentation-plan.md` 3번으로 신설(다른 두 항목과 +동일하게 아직 백로그 뼈대만). + +**2. rbvm GC 패턴 — "실물 검증됨" 근거 보강.** 사용자가 "GC 처리를 봐야 +한다면 rbvm을 확인하라, 실제 프로덕션에서 잘 돌아가는 걸 직접 확인한 +모듈"이라고 언급 — 실제로 rbvm의 GC 패턴(weak table 4종, `Instance. +Destroying` 기반 gcHold 클로저, 네임스페이스 Dispose 훅 등)은 이미 +`base/lifecycle-pattern.md`에 파일:라인까지 인용하며 상세 반영돼 있었지만 +"사용자가 직접 실행해서 확인했다"는 신뢰도 근거는 빠져있어서 그 문단을 +추가함(사람이 짠 코드라 100% 무결 보장은 아님 — 이미 발견된 버그 2건도 +근거로 같이 인용, 규범이 아니라 참고용 비교 대상이라는 톤 유지). + +**3. `.claude/` 코퍼스 전체 정리 패스.** 이전 세션들에서 쌓인 stale +참조/모순을 서브에이전트로 전수 감사 후 수정 — `modifier-plan.md`/ +`architecture.md`의 `research/component-composition-plan.md` 참조를 +승격된 `base/` 경로로 갱신, `comparison-fusion-vide.md`의 낡은 "Vide식 +암묵적 추적 vs Fusion식 명시적 축, quad는 미정" 서술을 실제 확정 사실 +(`bind-system-plan.md`의 `:With`+`:Compute` 명시적 모델 채택)로 정정, +`tween-plan.md`의 끊긴 절 참조 수정, `documentation-plan.md`의 인용 +오류 정정. `module-lifecycle-plan.md`가 스스로 "question.md에도 취합" +표시해뒀지만 누락돼 있던 "프로바이더" 이름(provider/processor/plug) +미정 항목도 `question.md`에 추가함. 여러 문서에 흩어져 있던 진짜 열린 +설계 질문들(Slot 형제 순서 보장, Attribute 타입 파라미터화, UI shorthand +이름 등)은 전부 `.claude/question.md`에 이미 반영되어 있음을 재확인만 +하고 임의로 결정하지 않음 — **이 파일이 여전히 "지금 열려있는 것"의 +단일 소스.** + +**4. Store `:Emit`, `:Compute`의 `previous` 인자, `state:Observer(fn)`, +Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.** 같은 세션에서 +더 이어진 Store/Ref 설계 논의, 전부 `base/store-semantics.md`와 +`base/bind-system-plan.md`에 반영됨: +- **`Store:Emit(key)`** — Source 원천에 한해서만 허용(중간/파생 State엔 + 없음). 존재 이유는 clone 불가능한 userdata/엔진 객체가 우선(편의성은 + 부차적). `Get()`이 라이브 레퍼런스를 주므로 캐시해서 비교/diff하면 + 안 된다는 캐비엇 명시. Modifier는 정적 flatten이라 Store/State 경로에 + 아예 안 걸치므로 Emit과 충돌할 지점 자체가 없음(따름정리: + `Store`의 `T`는 Modifier가 될 수 없음) — `store-semantics.md`. +- **`:Compute(fn)`의 선택적 두 번째 인자 `previous`** — Compute 결과 + 자체가 무거운 userdata인 경우(예: 큰 locale 테이블 → Roblox + `LocalizationTable` 변환) 재생성 대신 이전 결과를 재사용/patch하는 + 용도, opt-in. `previous`는 "정확히 한 단계 전"이 보장 안 되므로 반드시 + full diff로 다뤄야 함(React reconciler와 같은 모양). **핵심 캐비엇**: + 이 패턴은 결과 State가 계속 능동적으로 관측(정상 prop 바인딩 또는 + `state:Observer(fn)`+명시적 `Get()`)되지 않으면 mutate 로직 자체가 + 다시 실행 안 되어 조용히 영구 정지함 — `bind-system-plan.md`. +- **`state:Observer(fn)`** — 무효화 신호만 주고 값은 안 줌, `fn` 안에서 + 명시적으로 `Get()` 해야 실제 값을 얻음(기존 "emit은 저렴한 무효화 + 신호" 원칙 재사용). 반환값 자체가 `CreatedRef`처럼 children 배열에 + 바로 놓는 leaf 값(별도 `ObserverHolder` 래퍼 불필요, 사용자가 직접 + 단순화) — 그 leaf가 살아있는 동안만 구독 유지, `canExecute`로 게이팅. + `fn` 생략 시 "이 State를 그냥 계속 능동 관측 상태로 유지"하는 유틸로 + 씀(위 `previous` 캐비엇의 해결 도구). 구현은 값 내부가 아니라 외부 + weak table로 살아있는 Observer를 추적하는 방식 권장(rbvm + `getNamespaceOf`류 선례) — `bind-system-plan.md`. +- **Ref 일반화** — "quad가 만든 instance 전용"에서 "아무 사용자 값이나 + 담는 범용 값 박스"로 확장(object-ref/function-ref 안 나눔, React + `useRef`가 선례). `.Value` + `:Wait()`(coroutine 컨텍스트용) + 콜백 + 등록(복수 허용, 이미 채워져 있으면 즉시 1회 호출) — 이걸로 "코루틴 + 기반 대기 지원 미정"이던 항목 해소. `CreatedRef`는 이 위에 얹힌 특수 + 편의 패턴으로 재정리, 상충 없음. **one-shot 여부도 해소됨 — 반복 + 재설정 가능으로 확정**(React의 자식 재생성 시 ref 재사용 패턴이 선례, + 라벨 컨테이너 재사용 예시로 확인). 콜백은 발화 후에도 안 소진되고 + 매 `:Set()`마다 다시 불림 — 소진되는 건 `:Wait()`의 개별 대기자뿐. + **Ref는 의도적으로 lazy가 아니고 `:Compute` 파생도 지원 안 함** — + State와의 이 차이가 중요(예전에 Store가 Ref 역할도 겸했다가 lazy + 모델과 섞여서 안 좋았던 경험에서 나온 의도적 분리). Ref 정의 자체가 + 넓어졌으니 용어 정리 때 이름도 같이 재검토 대상. `question.md`의 + 관련 항목은 해소됨으로 갱신. From 83cd022011119cf7408d539f973fe8799a23c625 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 18:04:17 +0900 Subject: [PATCH 08/62] =?UTF-8?q?Ref/Store=20=EC=83=9D=EC=84=B1=EC=9E=90?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC,=20Observer=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ref(default)/Source(default)/Store({defaults}) — Compose식 팩토리 함수 생성자로 통일, Ref만 예외였던 이유 없었음(단순 명세 공백) - state:Observer(fn) 메소드 형태로 확정(자유 함수 아님) — 근거 명시 - PA님 코드의 기존 Observer 클래스와 이름 충돌 지점에 구분 각주 추가 Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 3b5ccda..6b180ba 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -338,7 +338,13 @@ stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. **결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` 래퍼 타입은 안 만듦 — `state:Observer(fn)`가 반환하는 값 자체가 이미 "children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. -`CreatedRef`와 완전히 같은 층위: +`CreatedRef`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 +아니라 메소드 `state:Observer(fn)`로 확정** — `state`가 항상 필요한 +필수 인자라 `:` 리시버 자리에 자연스럽게 들어가고(다른 형태면 인자 +두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), `architecture.md`의 +"함수지향 디폴트, `:` 체이닝은 예외적으로만(체이닝이 정말 편한 경우만)" +원칙이 정확히 이 경우를 가리킴 — Store 값 변경 체이닝과 같은 예외 +카테고리. ```lua local observer = state:Observer(function() @@ -490,6 +496,18 @@ Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 비효율이라는 게 사용자 판단("store가 source 수십 개 만드는건 비효율이니 둘이 다른 구현이라 봐도 될듯"). `Source(initial)` 류의 독립 생성자 (정확한 이름은 구현 단계에서 확정)가 Store와 나란히 존재. +- **생성자 스타일 확정(2026-08-06 후속 세션): Kotlin Compose식 "타입 + 이름 자체를 팩토리 함수로" — `Source(default)`, `Ref(default)`, + `Store({defaults})`.** Ref도 예외 없이 이 스타일을 따름 — Ref가 + `Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만 + 다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사 + 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 + 같은 스타일로 지원(안 하고 `Store()`만 있어도 되지만, 구현이 쉬우면 + 지원) — 내부적으로 입력 테이블을 그대로 들고 있지 않고 `__real`/ + metatable 저장 + `__newindex`/`__index` 프록시로 감싸면 됨. 이후 + 사용자가 그 defaults 테이블 원본을 직접 mutate하는 건 UB로 둠(방어 + 로직 불필요 — 오늘 세션에서 반복 확인된 "드문 오용 케이스를 위해 + 구조를 복잡하게 만들지 않는다"는 태도와 일치). **Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정** @@ -521,7 +539,10 @@ Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 `.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/ Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실제로 다른 -선택이 나와 재검토했으나 결론은 변경 없음: +선택이 나와 재검토했으나 결론은 변경 없음. **이름 주의**: 아래에서 말하는 +`Observer`는 PA님 코드의 클래스 이름(pub-sub, 8개 `subscribeXxx` 헬퍼)이고, +위 "`state:Observer(fn)`" 절에서 확정한 quad의 `Observer`와는 이름만 +같을 뿐 무관한 별개 개념 — 이 절은 순수 역사적 교차검증 기록으로만 읽을 것. - **전파 모델**: PA님의 pub-sub은 push-invalidate가 아니라 **push-값** (`Event:fire(...)`가 인자를 그대로 콜백에 전달, `Observable`의 `__newindex`가 From 2dcdebdee11d5462547f2221750f16dd3d116651 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 18:08:46 +0900 Subject: [PATCH 09/62] =?UTF-8?q?=EB=8F=85=EB=A6=BD=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=AF=B8=ED=8B=B0=EB=B8=8C=20vs=20=EC=9B=90=EC=B2=9C=20?= =?UTF-8?q?=EC=A2=85=EC=86=8D=20=ED=8C=8C=EC=83=9D=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=9B=90=EC=B9=99=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observer가 State처럼 원천(Source/State) 없이는 존재할 수 없다는 사용자 관찰을 일반 원칙으로 승격 — Source/Ref/Store/Modifier(독립 프리미티브, Type(args) 자유 함수 생성자) vs State/Observer(파생 데이터, 원천에 대한 메소드로만 얻어짐)로 분류. state:Observer(fn)가 메소드고 자유 함수 Observer(state, fn)가 없는 더 근본적인 이유로 연결. Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 6 +++++- .claude/base/store-semantics.md | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 6b180ba..64f649f 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -344,7 +344,11 @@ stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. 두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), `architecture.md`의 "함수지향 디폴트, `:` 체이닝은 예외적으로만(체이닝이 정말 편한 경우만)" 원칙이 정확히 이 경우를 가리킴 — Store 값 변경 체이닝과 같은 예외 -카테고리. +카테고리. **더 근본적인 이유**: `base/store-semantics.md`의 "독립 존재 +가능한 프리미티브 vs 원천에 종속된 파생 데이터" 원칙 참고 — Observer는 +State처럼 원천 없이는 존재할 수 없는 파생 데이터라, 애초에 "타입 +이름을 부르는 자유 함수 생성자" 카테고리에 안 속함(Source/Ref/Store/ +Modifier와는 다른 부류). ```lua local observer = state:Observer(function() diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index cd1a2f8..f76f7b5 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -64,6 +64,29 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 "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.Rounded(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 원천에 대한 메소드)을 +결정하는 기준으로 쓸 수 있음. + 과거 "미해결로 남은 것"으로 적었던 두 항목도 모두 해소됨: `:Compute`의 캐싱/무효화 전략은 push-invalidate(신호만)/pull-recompute(`Get()` 시점)로 확정(`base/bind-system-plan.md` "전파 모델" 절), `store "key"` 커링의 타입 From f1ca1567896f2185fbcfc203b80177936c2f6432 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 18:45:28 +0900 Subject: [PATCH 10/62] =?UTF-8?q?Modifier=20=EB=A7=88=EB=AC=B4=EB=A6=AC(Ge?= =?UTF-8?q?tter=20=EC=A0=9C=EA=B1=B0,=20=5F=5Findex=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=ED=86=B5=EC=B0=B0)=20+=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20store-bind=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modifier Getter 아예 안 만들기로 확정 — :FontSize(function(old)->new)가 유일한 use case를 이미 인라인으로 커버 - old는 항상 "현재 저장된 그대로"(plain/State 구분 없이) 넘긴다는 원칙 명문화 - func(state)->state 세 번째 셋터 모양은 불필요하다고 검토 후 기각 - Modifier는 핸들러 계층(Ref/Slot)을 몰라도 되는 순수 데이터 merge 레이어로 확정 - Modifier 런타임은 base에 제네릭 __index 하나로 충분 — 클래스별 타입 생성기는 정적 타입 체크 전용, 런타임과 무관하다는 점 명시 - 이벤트도 store-bind 가능하도록 확정 (기존 재실행 래핑 재사용, false를 disconnect 센티널로) — quad-roblox 로컬 - CLAUDE.md 핸드오버 5번 항목 갱신 Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 32 +++++++++++-- .claude/base/modifier-plan.md | 77 ++++++++++++++++++++++++-------- CLAUDE.md | 26 +++++++++++ 3 files changed, 112 insertions(+), 23 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 64f649f..556fa6a 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -265,10 +265,12 @@ SyntheticEvent만 주는 것과 같은 모양). 충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로 (`base/lifecycle-pattern.md`, rbvm 선례 — GC-native), Destroy되면 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 - 불필요. 동적으로 Connect/Disconnect를 반복해야 하는 최적화 케이스가 - 실제로 생기면, 그건 Ref로 얻은 Instance를 갖고 사용자 코드가 직접 - 처리하면 됨(사용자가 실사용 케이스로 확인한 바로도 이런 니즈는 - 드묾 — 드문 케이스를 위해 구조 전체를 복잡하게 만들 이유 없음). + 불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 + 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 + 지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`로 + disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 + 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 + 메커니즘 불필요). **일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — "엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 @@ -277,6 +279,28 @@ SyntheticEvent만 주는 것과 같은 모양). 문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 참고할 만한 템플릿 정도로만 취급. +## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션) + +**결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 +바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. + +**엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable +바인드를 재실행하는 래핑"(위 절, `process`가 값이 바뀔 때마다 +`process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 같이 +호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. +이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을 +per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는 +것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/ +`priority`/`process`/`retract`)만 제대로 구현하면 됨. + +**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예 +없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는 +신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤 +타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면 +`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함. +이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 +boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 없음. + ## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양 **사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index a8c2672..3e42e78 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -1,8 +1,9 @@ # Modifier 설계 (정적 merge, immutable 체이닝) **상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable -값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. 남은 -건 getter 정확한 이름뿐(구현 단계). Modifier가 컴포넌트 경계를 어떻게 +값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. +**Getter는 별도로 안 만들기로 확정(2026-08-06 후속 세션)** — 아래 "4. +Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. Modifier가 컴포넌트 경계를 어떻게 통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로 **[정정] `research/component-composition-plan.md`는 2026-08-04 세션에 수렴 완료돼 `base/component-composition-plan.md`로 승격됨 — 이 문서는 @@ -67,30 +68,68 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서 낫다고 판단 — **immutable 기본으로 확정**. -### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음 +### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음, 별도 Getter는 없음 `:FontSize(value)`(리터럴) / `:FontSize(function(current) return -current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 콜백, -여러 줄로 풀어쓰고 싶을 때는 현재 값을 getter로 꺼내 계산 후 리터럴로 -다시 넣는 스타일 둘 다 인체공학상 필요하다고 판단. +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도 그 테이블 필드를 직접 읽으면 됨. -**Getter 정확한 모양은 미정** — `mod:Get("FontSize")` 같은 전용 메소드로 -할지, 아니면 Store/DI 관습처럼 dot-access(`mod.fontSize`) 자체가 읽기 -경로를 겸하게 해서 별도 `:Get()`이 아예 불필요하게 할지는 구현 단계에서 -확정. **다만 getter의 동작 자체은 확정**: 필드가 State면 getter 호출이 -곧 관측이라 그 순간 계산되어 확정된(더 이상 반응하지 않는) 값이 반환됨 — -`base/bind-system-plan.md`의 "관측해야 실체화된다" 전역 원칙 그대로 적용 -(아래 참고). +**런타임은 클래스별 코드 없이 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는 핸들러 계층을 모름 — 순수 데이터 merge 레이어.** Modifier는 +`process`/`retract`/`isHandlable` 핸들러 레지스트리보다 아래층(위 "1. +런타임 pluggable 핸들러 아님" 참고)이라, Ref/Slot 같은 핸들러 계층 +개념을 아예 몰라도 됨 — 필드에 Ref나 Slot 같은 값이 들어가도 Modifier +입장에선 그냥 flatten해서 최종 props 테이블에 얹을 뿐. 권장하는 사용법은 +아니지만(Modifier는 스타일링 데이터 지향이니), 막을 이유도 없음 — 방어 +로직 없는 UB로 남겨둠(오늘 세션 내내 반복된 "드문 오용까지 방어하려고 +구조 복잡하게 안 만든다" 원칙과 동일). ### 4-1. 필드가 State일 수도 있음 — Setter가 State/plain 여부로 분기 @@ -108,10 +147,9 @@ plain이냐에 따라 setter 내부 동작이 갈려야 함** — 새 개념이 | **State** | **리터럴** | clone 후 **State를 통째로 리터럴로 덮어씀 — 의도적으로 반응성이 끊김**(Store의 "부분 오버레이 없음, 전체 교체" 원칙과 같은 결) | | **State** | **함수** | clone 후 `field:Compute(fn)`으로 **새 파생 State**를 만들어 대입 — 반응성 유지, State의 기존 `:Compute` 메커니즘에 그대로 위임 | -즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터(혹은 -getter로 꺼내 계산 후 리터럴로 다시 넣는 멀티라인 스타일)는 그 순간 값을 -확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는 것으로 -문서화. +즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터는 그 +순간 값을 확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는 +것으로 문서화. ### 4-2. Modifier는 소유권/유일성 제약이 없음 @@ -162,8 +200,9 @@ State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어 ## 열린 질문 (`.claude/question.md`에도 취합) -- Getter 정확한 이름/모양(`:Get(key)` vs dot-access 겸용) — 후순위, 구현 - 단계에서 다른 세부 API 이름들과 같이 확정 가능. +- **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter + 자체를 안 만들기로 확정(위 "4. Setter는..." 절 참고), 더 이상 열린 + 질문 아님. - Modifier가 컴포넌트 경계를 어떻게 통과하는지는 **[정정] 이미 `base/component-composition-plan.md`에서 해소됨**(named parameter로 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 diff --git a/CLAUDE.md b/CLAUDE.md index a28b2c3..50ec0cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -364,3 +364,29 @@ Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.** 모델과 섞여서 안 좋았던 경험에서 나온 의도적 분리). Ref 정의 자체가 넓어졌으니 용어 정리 때 이름도 같이 재검토 대상. `question.md`의 관련 항목은 해소됨으로 갱신. + +**5. Observer 이름 확정, Ref/Source/Store 생성자 스타일, "독립 프리미티브 vs +파생 데이터" 원칙, Modifier 세부 마무리 — 전부 확정, base 문서 반영 완료.** +- `Observer`로 확정(`ObserverHandle` 아님) — `:Connect()`→`Connection`과 + 같은 기존 명명 관례. PA님 코드의 동명 클래스와는 무관, 각주로 구분. +- **생성자 스타일**: `Source(default)`/`Ref(default)`/`Store({defaults})` + — Kotlin Compose식 "타입 이름 자체가 팩토리 함수". Ref만 예외였던 이유 + 없었음(단순 명세 공백). +- **일반 원칙 신설**: 독립 존재 가능한 프리미티브(Source/Ref/Store/ + Modifier, `Type(args)` 자유 함수 생성자) vs 원천에 종속된 파생 데이터 + (State/Observer, 원천에 대한 메소드로만 얻어짐) — `state:Observer(fn)`가 + 메소드고 자유 함수가 없는 더 근본적인 이유로 연결(`store-semantics.md`). +- **Modifier 마무리**: (a) Getter를 아예 안 만들기로 확정 — + `:FontSize(function(old)->new)`가 유일했던 use case를 인라인으로 커버. + (b) `old`는 항상 "현재 저장된 그대로"(plain이면 raw, State면 State + 핸들) 넘김 — `:Compute`의 self와 같은 결. (c) `func(state)->state`라는 + 세 번째 인자 모양은 불필요(함수 합성 + State 직접 대입으로 이미 커버). + (d) Modifier는 핸들러 계층(Ref/Slot 등)을 몰라도 됨 — 순수 데이터 + merge 레이어라 UB로 흘려보내도 문제없음. (e) **런타임 구현은 base에 + 제네릭 `__index` 하나면 충분** — `mod:FontSize(...)`가 `__index(self, + "FontSize")`로 잡히므로 클래스별 런타임 코드 불필요, FrameModifier류 + 타입 생성기는 순전히 정적 타입 체크만을 위한 것. (f) 이벤트도 + store-bind 가능하도록 확정 — 기존 재실행 래핑 재사용, `false`를 + disconnect 센티널로 씀(`nil`은 테이블에서 사라져서 부적합) — + `bind-system-plan.md`. Modifier가 이벤트 키를 담아도 되는지는 (d)로 + 자동 해소(Modifier가 애초에 키 종류를 구분 안 하므로). From 20fad2508f4b06d2b88604a00e074ccbb8ebb831 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 19:33:07 +0900 Subject: [PATCH 11/62] =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20store-bind?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=80=EC=B0=A8=EC=A0=81=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AA=85=EC=8B=9C=20=E2=80=94=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=ED=8C=A8=ED=84=B4=EC=9D=80=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC+=EB=82=B4=EB=B6=80=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재고 결과: 저빈도 UI 이벤트의 조건부 처리는 Connect/Disconnect 없이 "핸들러 하나 계속 연결 + 내부 분기"로 이미 공짜로 되고 더 쌈 — 이걸 기본 권장 패턴으로 명시. store-bind(false 센티널)는 고빈도 신호/로직 자체가 바뀌는 드문 케이스를 위한 부차적 옵션으로 격하, 자주 재계산되는 State에 물리면 숨은 churn 비용이 생긴다는 캐비엇 추가. 메커니즘 자체는 일관성을 위해 그대로 유지(예외로 빼서 막을 근거는 약함). 향후 documentation-plan.md 3번 문서에 두 패턴 대조 예정으로 기록. Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 28 ++++++++++++++++++++++++++ .claude/research/documentation-plan.md | 7 +++++++ 2 files changed, 35 insertions(+) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 556fa6a..05f162d 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -301,6 +301,34 @@ per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect() 이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 없음. +**quad가 미는 기본 패턴은 아님 — 부차적 옵션.** 저빈도 UI 이벤트(클릭류)를 +조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러 +하나를 계속 연결해두고 안에서 분기하면 끝: + +```lua +MouseButton1Click = function() + if not store.enabled:Get() then return end + ... +end +``` + +이 "핸들러 하나 + 내부 분기" 패턴이 Connect/Disconnect 자체가 없어서 더 +싸고, Roblox/React 어디서든 이미 익숙한 관용구라 **기본 권장 패턴**. +store-bind 방식이 실제로 값어치 있는 지점은 고빈도 신호(Heartbeat/ +RenderStepped/마우스 무브처럼 안 쓸 때 Connection을 살려두는 것 자체가 +낭비인 경우)나, 단순 on/off가 아니라 로직 자체가 바뀌는 드문 케이스. +자주 재계산되는 State에 이벤트를 직접 물리면 매 재계산마다 Disconnect+ +Connect가 도는 숨은 churn 비용도 있음(Store Set은 dedup 안 함, +`store-semantics.md`) — 그래서 남용하지 말라는 캐비엇. + +**그래도 일관성 있게 지원은 해둠.** "저빈도엔 필요 없다"가 "그러니 예외로 +빼고 못 하게 막자"로 이어질 이유는 없음 — 프로퍼티/태그/어트리뷰트가 +전부 store-bind되는데 이벤트만 특별 취급해서 뺄 근거가 약하고, 구현 +비용도 낮으니(위 "엔지니어링 비용이 낮은 이유" 참고) 일관되게 지원해두는 +쪽을 택함. 그냥 "이런 것도 가능하다" 정도로 존재하고, quad가 이 패턴을 +적극 권장하진 않는다는 톤으로 문서화(`research/documentation-plan.md` +3번 "권장 이벤트 핸들링 패턴" 문서에 이 대조까지 반영 예정). + ## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양 **사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md index 29e643e..e940e7b 100644 --- a/.claude/research/documentation-plan.md +++ b/.claude/research/documentation-plan.md @@ -75,6 +75,13 @@ UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같 - 일반화된 원칙("엔진이 네이티브로 콜백에 뭘 주든 그대로 호출해줘도 무방하다")을 quad-roblox 한정 설명으로 둘지, 다른 백엔드 구현자를 위한 일반 가이드로도 남길지는 미정. +- **조건부 이벤트 처리 — 기본 패턴 vs store-bind, 대조해서 보여줄 것** + (2026-08-06 추가). "핸들러 하나 계속 연결 + 내부에서 `store.enabled:Get()` + 분기"가 기본 권장 패턴이고, 이벤트 store-bind(`false`로 disconnect, + `bind-system-plan.md` "이벤트도 store-bind 가능" 절)는 고빈도 신호나 + 로직 자체가 바뀌는 드문 케이스를 위한 부차적 옵션이라는 점을 코드 + 대조로 보여줄 것 — quad가 미는 디자인은 전자, 후자는 "이럴 수도 있다" + 정도로만 소개. - 어느 문서에 넣을지 — 퀵스타트? 위 1번 네이밍 컨벤션 문서와 마찬가지로 아직 미정. From 2467c68ebb8b28b4b59c9ac02943ef1dc927183f Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 19:40:15 +0900 Subject: [PATCH 12/62] =?UTF-8?q?Observer=20:Subscribe()/:Unsubscribe()=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=A0=84=EC=97=AD/=EB=8F=85?= =?UTF-8?q?=EB=A6=BD=20=EC=82=AC=EC=9A=A9=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit children 배열에 안 붙는 Observer(디버깅용 Store 직접 print 패턴 등)를 위한 명시적 라이프사이클 경로. PA님 코드 교차검증 때 예고해둔 확장 지점("GC만으로 부족하면 명시적 dispose 경로 추가 가능")을 실제로 채움. liveness는 self.Subscribed 필드 우선 + self.Connection.Connected 폴백, 내부 강참조 레지스트리로 GC 방지(weak table과 역할 분리). 둘 다 idempotent, Unsubscribe는 자동(리프) 케이스 조기 해제에도 재사용. CLAUDE.md 핸드오버 6번 항목 갱신(이벤트 store-bind 부차적 옵션 재조정 포함). Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 47 +++++++++++++++++++++++++++++--- CLAUDE.md | 21 ++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 05f162d..63b9612 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -442,10 +442,49 @@ retract/Destroy되면 자동으로 정리됨. 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자" 절의 캐비엇("능동적 관측 경로가 안 남아있으면 mutate 로직이 조용히 - 멈춘다")을 만족시키는 가장 단순한 도구 — 별道 콜백 로직 없이 그냥 - 이 State가 계속 재계산되게만 강제하고 싶을 때 씀 — 별도 콜백 로직 없이 - 이 용도로만 쓰고 싶을 때. 문서화만 확실히 하면 별문제 없음(사용자 - 판단). + 멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥 + 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 + 하면 별문제 없음(사용자 판단). + +### `:Subscribe()`/`:Unsubscribe()` — 리프에 안 붙는 "전역/독립" Observer용 (2026-08-06 후속 세션) + +**문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 +"어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 +전제를 깨뜨림 — 개발자가 디버깅용으로 `RunService:IsStudio()` 가드 +안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue +로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 +엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table +기반 자동 추적이 적용 안 됨. + +**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 +설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클 +절)에서 이미 예고해둔 확장 지점을 실제로 채우는 것 — "나중에 GC만으로 +정말 부족한 케이스가 생기면 명시적 dispose 경로를 추가로 얹는 게 가능한 +디자인"이라고 그때 이미 못박아뒀음. + +- **`local` 변수로 참조만 들고 있는 것으로는 부족한 이유**: 토글(BooleanValue로 + 로깅 껐다 켰다) 케이스에서, 참조를 끊어도 실제 GC는 결정론적으로 즉시 + 일어나지 않음 — "껐다"고 생각한 뒤에도 한동안 계속 발화할 수 있음. + `:Unsubscribe()`는 즉시/결정론적으로 끊는 경로라 이 문제가 없음. +- **liveness 체크는 필드 우선, weak table은 폴백**(사용자 제안): 외부 + weak table 조회보다 리터럴 필드 접근이 더 쌈(Luau가 문자열 키 접근을 + 미리 해시해둠) — + ```lua + if self.Subscribed then return true end + if self.Connection then return self.Connection.Connected end + ``` + 자동(리프 부착)/수동(구독) 두 라이프사이클 경로를 하나의 `canExecute`류 + predicate로 OR 묶는 자연스러운 형태. 실측은 구현 단계에서 확인. +- **내부 강참조 레지스트리**: `SubscribedObservers: {[observer]: true}`류를 + **weak 아닌 강참조**로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적 + 자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림 + (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). +- **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 + 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 + no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. +- **`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀** — Instance가 + 파괴되기 전에 수동으로 조기 해제하고 싶을 때도 같은 메소드 하나로 + 충분, 별도 API 안 만듦. ## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 diff --git a/CLAUDE.md b/CLAUDE.md index 50ec0cc..11ef18b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,3 +390,24 @@ Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.** disconnect 센티널로 씀(`nil`은 테이블에서 사라져서 부적합) — `bind-system-plan.md`. Modifier가 이벤트 키를 담아도 되는지는 (d)로 자동 해소(Modifier가 애초에 키 종류를 구분 안 하므로). + +**6. 이벤트 store-bind는 부차적 옵션으로 재조정, Observer의 `:Subscribe`/ +`:Unsubscribe` 추가 — 둘 다 확정, 반영 완료.** +- 이벤트 store-bind(5번 (f))를 다시 검토 — "구현이 쉽다"가 "구현할 + 가치가 있다"를 보장 안 함을 재확인. 저빈도 UI 이벤트의 조건부 처리는 + "핸들러 하나 계속 연결 + 내부에서 `store.enabled:Get()` 분기"가 이미 + Connect/Disconnect 없이 더 싸고 표준적이라 **이걸 기본 권장 패턴으로 + 확정**. store-bind는 고빈도 신호(Heartbeat 등)나 로직 자체가 바뀌는 + 드문 케이스를 위한 부차적 옵션으로 격하(메커니즘 자체는 유지 — 일관성 + 위해 예외로 뺄 근거는 약함). 자주 재계산되는 State에 물리면 Connect/ + Disconnect churn이 숨은 비용이 된다는 캐비엇도 추가. +- **Observer의 `:Subscribe()`/`:Unsubscribe()`** — children 배열에 안 + 붙는 "전역/독립" Observer(디버깅용으로 Store에 직접 걸어 print하는 + 흔한 패턴, `RunService:IsStudio()` 가드 + BooleanValue 토글)를 위한 + 명시적 라이프사이클 경로. 이건 새 설계가 아니라 PA님 코드 교차검증 + 때 이미 예고해둔 확장 지점("GC만으로 부족하면 명시적 dispose 경로 + 추가 가능")을 실제로 채운 것. liveness 체크는 `self.Subscribed` 필드 + 우선, `self.Connection.Connected` 폴백(필드 접근이 weak table 조회보다 + 쌈). 내부 레지스트리는 자동 케이스의 weak table과 별개로 강참조 + (weak면 "살려둔다"는 목적이 무의미해짐). 둘 다 idempotent, `:Unsubscribe()`는 + 자동 케이스 조기 해제에도 재사용. From c9a0a3b461f72f168892e9b5dea05404e934fcf4 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 19:44:56 +0900 Subject: [PATCH 13/62] =?UTF-8?q?Observer=20Subscribe/Unsubscribe=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=ED=99=95=EC=A0=95=20=E2=80=94=20self=20?= =?UTF-8?q?=EB=A6=AC=ED=84=B4,=20nil=20=EC=B2=98=EB=A6=AC,=20=EB=AC=B4?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=83=9D=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :Unsubscribe()가 강참조 레지스트리에서 반드시 nil 처리까지 해야 함을 명시 - 참조를 아무 데도 안 담고 state:Observer(fn):Subscribe()만 해도 정상적으로 계속 도는 게 의도임을 명시 - :Subscribe()/:Unsubscribe() 둘 다 self 리턴하는 것으로 확정(대칭) — 체이닝/리스트 저장 편의 Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 63b9612..b7c280a 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -479,12 +479,27 @@ retract/Destroy되면 자동으로 정리됨. **weak 아닌 강참조**로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적 자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림 (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). + **`:Unsubscribe()`는 이 레지스트리에서 반드시 `SubscribedObservers[observer] + = nil`까지 해야 함** — `Subscribed` 플래그만 내리고 강참조를 안 끊으면 + GC 대상이 안 되는 반쪽짜리 해제가 됨, 둘은 항상 같이 일어나는 한 세트. - **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. - **`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀** — Instance가 파괴되기 전에 수동으로 조기 해제하고 싶을 때도 같은 메소드 하나로 충분, 별도 API 안 만듦. +- **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상** + — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 + 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 + 포인트). +- **`:Subscribe()`/`:Unsubscribe()` 둘 다 `self`를 리턴(대칭)** — + `local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에 + 끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn) + :Subscribe())`처럼 리스트에 담을 때도 줄바꿈 없이 됨. Observer가 + immutable 값이 아니라 원래 mutable한 구독 핸들이라 fluent 체이닝이 + 자연스러움 — Modifier의 clone-then-return 체이닝과는 다른 이유(같은 + 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 + 체이닝 가능. ## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 From e58ff06012fe2fd2b3b2cd21246125bb37c18ea7 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 19:46:35 +0900 Subject: [PATCH 14/62] =?UTF-8?q?clear=20=EC=A0=84=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EC=A0=90=EA=B2=80=20=E2=80=94=20question.md=EC=97=90=20?= =?UTF-8?q?=EB=B9=A0=EC=A1=8C=EB=8D=98=20=EB=91=90=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 채팅에서만 언급되고 question.md엔 안 적혀있던 것 발견해 추가: - DI 리네임이 FrameModifier류 Modifier 타입 프리픽스에 주는 파급 효과 - Ref 정의가 넓어지며 생긴 이름 재검토 필요성 그 외 전체 코퍼스 stale 참조(ObserverHolder, getter, Subscribled 오타 등) 재점검 완료 — 발견된 문제 없음. 이 세션에서 다룬 내용은 전부 question.md/ base 문서/CLAUDE.md 핸드오버에 반영 완료. Co-Authored-By: Claude Sonnet 5 --- .claude/question.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.claude/question.md b/.claude/question.md index ef65b47..5edd49c 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -26,6 +26,9 @@ - **`DI`(Declarative Instance, 1순위)**: "Dependency Injection"의 업계 표준 축약어와 완전히 겹침 — 4차 라운드에서 이미 한 번 실제로 오해가 있었던 전례(`base/bind-system-plan.md`의 "인스턴스 생성" 절 참고). + **파급 효과(2026-08-06 추가)**: `DI`가 리네임되면 `DI.FrameModifier`류 + Modifier 클래스별 타입 프리픽스도 같이 바뀌어야 함 — `DI` 리네임 논의 + 때 이 연쇄까지 같이 고려할 것. - **`PerInstanceState`(2순위)**: 핵심 프리미티브 `State`와 이름이 겹쳐서 실제로는 완전히 무관한 유틸(인스턴스별 weak-keyed 저장소)인데 혼동 유발 가능 — `PerInstanceStorage`/`InstanceData` 등 대안. @@ -35,6 +38,10 @@ - **`CreatedRef`/`canExecute`(3순위, 사소함)**: `CreatedRef`는 과거분사형이라 생성자처럼 안 읽힘. `canExecute`는 실제로 "이 핸들이 아직 살아있나" 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. +- **`Ref`(3순위, 2026-08-06 추가)**: 정의가 "quad가 만든 instance를 얻는 + 통로"에서 "아무 사용자 값이나 담는 범용 값 박스"로 넓어져서(`base/ + bind-system-plan.md` "Ref 일반화" 절), 이름이 여전히 넓어진 의미에 + 맞는지 재검토 대상. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ From 34a0e1dcce34b429d2562a214284f4b109d9e497 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 20:10:15 +0900 Subject: [PATCH 15/62] =?UTF-8?q?v1=20=ED=95=98=EC=9C=84=ED=98=B8=ED=99=98?= =?UTF-8?q?(compat)=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=ED=83=80=EB=8B=B9?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=ED=86=A0=20=EB=A6=AC=EC=84=9C=EC=B9=98=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 질문(v2가 v1에 얇은 compat 래퍼를 제공할 수 있는가)에 답하기 위한 조사. quad2-try의 quad-compat 서브패키지는 빈 폴더로 실제 시도된 적 없었음을 확인 — 반복 조사 금지 대상이 아니라 새로 검토 가능한 주제. 결론: 이벤트 self 관습 등 표면 문법은 opt-in 패키지로 얇게 재현 가능하지만, Class.Extend()의 자동-store+자동재렌더 같은 핵심 런타임은 v1/v2가 컴포넌트 정체성 모델 자체를 다르게 정의해서 얇게 안 됨. 방향 결정(부분 compat vs 마이그레이션 가이드)은 question.md에 열린 질문으로 반영. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 1 + .claude/question.md | 7 ++ .claude/research/v1-compat-plan.md | 119 +++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 .claude/research/v1-compat-plan.md diff --git a/.claude/README.md b/.claude/README.md index b37aa38..ca6d0a2 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -45,6 +45,7 @@ | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | UI 네이밍 컨벤션 문서 + Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 — 뼈대만, `debug-tooling-plan.md` 논의에서 파생 | 하 — 착수 시점 미정, 뼈대만 기록해둔 상태 | | `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | +| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 타당성 검토 — 표면 문법(이벤트 self 등)은 opt-in 서브패키지로 얇게 가능, 핵심 런타임(`Class.Extend()` 자동-store+자동재렌더)은 컴포넌트 정체성 모델 충돌로 얇게 안 됨. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — 방향(부분 compat vs 마이그레이션 가이드) 사용자 결정 대기 | ## 참고 diff --git a/.claude/question.md b/.claude/question.md index 5edd49c..eb82b80 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -86,6 +86,13 @@ ui-shorthand-plan.md`. 기능 필요 여부(여전히 필요로 재확정)·메커니즘 (Handler)·패키지 배치(quad-roblox 코어)는 확정, 남은 건 이름 재검토 (용어 정리 합류)와 `RoundSize`(이미지 라운드) 대체 방식뿐. +- **v1 하위호환(compat) 레이어 방향** — `research/v1-compat-plan.md`(신규, + 2026-08-06). 타당성 평가 결론: 이벤트 self 관습 등 표면 문법은 opt-in + 서브패키지로 얇게 가능, `Class.Extend()` 자동-store+자동재렌더 같은 + 핵심 런타임은 컴포넌트 정체성 모델(v1 mutate vs v2 선언적 재구성) 자체가 + 달라 얇게 안 됨. 사용자 결정 필요: 목표가 "일부 문법만 익숙하게" 인지 + "v1 코드를 거의 무수정으로 돌리는 것"인지 — 후자면 compat보다 마이그레이션 + 가이드가 더 현실적이라는 게 문서의 권장. ## 참고: 지금까지 확정된 것 (요약) diff --git a/.claude/research/v1-compat-plan.md b/.claude/research/v1-compat-plan.md new file mode 100644 index 0000000..3481afe --- /dev/null +++ b/.claude/research/v1-compat-plan.md @@ -0,0 +1,119 @@ +# 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()`을 통한 양방향 프로퍼티 바인딩(Linker, `class.lua:352-358`) +- `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`은 이미 + `research/ui-shorthand-plan.md`에서 네이티브 포팅 확정됨 — 별도 compat + 작업 불필요. `RoundSize`(이미지 9-slice 라운드 트릭)만 UICorner 없던 + 시절 워크어라운드라 재현 자체가 불필요하다고 이미 결론남. +- **`target()` 양방향 바인딩**: v2 State가 이미 get/set 가능한 핸들이라 + "두 State를 서로 미러링하는 어댑터 함수" 정도는 가능해 보임 — 정확한 + 구현은 미검증, 착수 시점에 확인 필요. +- **오브젝트 태그 조회**: 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 코드를 무수정으로 돌리는 완전 하위호환"은 사실상 불가능하거나, + 적어도 "얇은 래퍼"라는 표현에 맞지 않음** — 근본 원인은 API 이름 + 불일치가 아니라 컴포넌트 정체성 모델(OOP mutate vs 선언적 재구성) 충돌. +- **문법 설탕 수준의 부분 compat은 시도할 가치 있음** — 이벤트 self 관습, + 양방향 바인딩 헬퍼 정도는 opt-in 서브패키지로 저비용 제공 가능. 단 이건 + "v1 코드가 그대로 돈다"가 아니라 "v1에서 넘어온 사람이 익숙한 문법 + 일부를 v2 컴포넌트 함수 안에서 부분적으로 쓸 수 있다"는 수준. +- **"v1 코드베이스 자체를 옮기는 것"이 목표라면 compat보다 마이그레이션 + (자동 변환 스크립트 또는 수동 포팅 가이드)이 더 현실적** — `question.md`에 + 이미 있는 "v1 `objectListClass.__newIndex` 오타 기능 재현 테스트, 마이그레이션 + 가이드 작성 시점에 필요" 항목도 이 방향을 전제하고 있어 자연스럽게 합류 + 가능. +- 확정된 소스 트리(`base/architecture.md` "구현 착수" 절)는 `quad-base`/ + `quad-roblox` 두 패키지뿐 — `quad-compat`은 그 목록에 없음. 부분 + compat을 실제로 만든다면 세 번째 패키지를 새로 추가하는 결정이 필요함. + +## 5. 열린 질문 (사용자 판단 필요) + +- 목표가 "v1 문법 일부를 v2에서도 익숙하게 쓰게" 인지, "v1 코드를 무수정 + 또는 최소 수정으로 돌리는 것"인지 — 답에 따라 부분 compat vs 마이그레이션 + 가이드 중 방향이 갈림. +- 부분 compat으로 간다면 `quad-compat`을 정식 세 번째 서브패키지로 추가할지. +- 이벤트 self 관습을 compat에서 되살릴 때, `base/bind-system-plan.md`가 + 이미 명시한 반대 근거 4번(quad-debug 추적 밖 mutate 경로)을 어떻게 + 처리할지(quad-debug는 어차피 후순위라 지금 결정 불필요할 수도 있음). + +## 착수 시점 + +지금 당장 설계/착수 불필요 — `CLAUDE.md` "지금 할 일" 1번(구현 착수, +ROADMAP M0)이 최우선. 이 문서는 타당성 평가 결과만 남겨두고, 실제 설계는 +사용자가 방향(부분 compat vs 마이그레이션 가이드)을 정한 뒤 진행. From 24c82a299ea91f0adaba01c052ee94c70179dedb Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 20:19:19 +0900 Subject: [PATCH 16/62] =?UTF-8?q?v1-compat-plan.md:=20=EB=B3=91=ED=96=89?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20+=20=EA=B2=BD=EA=B3=84=20=EB=A6=AC?= =?UTF-8?q?=EC=A1=B8=EB=B8=8C=20=EB=B8=8C=EB=A6=AC=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EC=88=98=EB=A0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 "v1 런타임을 v2 위에 재구현" 대신 "v1을 그대로 병행 실행하고 경계에서만 값을 리졸브해 넘기는 브리지"를 제안 — 검토 결과 기존에 설계된 state:Observer()(무인자="계속 관측" 유틸)와 v1의 공개 프로퍼티 재대입 API만으로 조립 가능함을 확인, DOMless/엔진값 원칙 덕에 구조적 합성도 이미 공짜라 3-2("얇게 안 됨") 문제를 재구현이 아니라 회피로 해결하는 유력 방향으로 수렴. 조사 중 target()/Linker를 "양방향 바인딩"으로 서술한 이전 오류도 정정(실제로는 named child 등록 + 시그널 중계). Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 2 +- .claude/question.md | 13 ++-- .claude/research/v1-compat-plan.md | 119 ++++++++++++++++++++++------- 3 files changed, 99 insertions(+), 35 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index ca6d0a2..049bbdc 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -45,7 +45,7 @@ | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | UI 네이밍 컨벤션 문서 + Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 — 뼈대만, `debug-tooling-plan.md` 논의에서 파생 | 하 — 착수 시점 미정, 뼈대만 기록해둔 상태 | | `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | -| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 타당성 검토 — 표면 문법(이벤트 self 등)은 opt-in 서브패키지로 얇게 가능, 핵심 런타임(`Class.Extend()` 자동-store+자동재렌더)은 컴포넌트 정체성 모델 충돌로 얇게 안 됨. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — 방향(부분 compat vs 마이그레이션 가이드) 사용자 결정 대기 | +| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 타당성 검토 — v1 런타임 재구현(문법 흉내)은 컴포넌트 정체성 모델 충돌로 얇게 안 됨, 대신 **v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`+v1 프로퍼티 재대입으로 값 리졸브해 넘기는 브리지**가 유력 방향으로 수렴(사용자 제안). quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — 브리지 세부 범위(양방향 필요 여부, 패키지 위치) 사용자 결정 대기 | ## 참고 diff --git a/.claude/question.md b/.claude/question.md index eb82b80..cbe7a1f 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -87,12 +87,13 @@ (Handler)·패키지 배치(quad-roblox 코어)는 확정, 남은 건 이름 재검토 (용어 정리 합류)와 `RoundSize`(이미지 라운드) 대체 방식뿐. - **v1 하위호환(compat) 레이어 방향** — `research/v1-compat-plan.md`(신규, - 2026-08-06). 타당성 평가 결론: 이벤트 self 관습 등 표면 문법은 opt-in - 서브패키지로 얇게 가능, `Class.Extend()` 자동-store+자동재렌더 같은 - 핵심 런타임은 컴포넌트 정체성 모델(v1 mutate vs v2 선언적 재구성) 자체가 - 달라 얇게 안 됨. 사용자 결정 필요: 목표가 "일부 문법만 익숙하게" 인지 - "v1 코드를 거의 무수정으로 돌리는 것"인지 — 후자면 compat보다 마이그레이션 - 가이드가 더 현실적이라는 게 문서의 권장. + 2026-08-06, 같은 날 후속 논의로 수렴). v1 런타임을 v2 위에 재구현하는 + 건(문법 흉내) 컴포넌트 정체성 모델 충돌로 얇게 안 되지만, **v1을 그대로 + 두고 v2와 병행 실행 + 경계에서만 `state:Observer()`(lazy 포기, 항상 + 관측)로 값을 리졸브해 v1 프로퍼티에 재대입하는 브리지**는 유력 방향으로 + 수렴(사용자 제안, 기존 base 원시들만으로 조립 가능함을 확인). 남은 결정: + 이 브리지가 양방향(v1→v2도) 필요한지, 브리지 글루 코드를 별도 패키지로 + 뺄지. ## 참고: 지금까지 확정된 것 (요약) diff --git a/.claude/research/v1-compat-plan.md b/.claude/research/v1-compat-plan.md index 3481afe..082f173 100644 --- a/.claude/research/v1-compat-plan.md +++ b/.claude/research/v1-compat-plan.md @@ -31,7 +31,14 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층 - 이벤트 핸들러가 첫 인자로 `self`(or `this`)를 받는 관습(`event.lua:81-83`) - 프로퍼티 테이블의 특수 키(`RoundSize`/`Corner`/`PaddingAll`/`Scale`, `class.lua:134-213`) -- `target()`을 통한 양방향 프로퍼티 바인딩(Linker, `class.lua:352-358`) +- `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 컴포넌트 모델 그 자체: @@ -57,9 +64,10 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층 `research/ui-shorthand-plan.md`에서 네이티브 포팅 확정됨 — 별도 compat 작업 불필요. `RoundSize`(이미지 9-slice 라운드 트릭)만 UICorner 없던 시절 워크어라운드라 재현 자체가 불필요하다고 이미 결론남. -- **`target()` 양방향 바인딩**: v2 State가 이미 get/set 가능한 핸들이라 - "두 State를 서로 미러링하는 어댑터 함수" 정도는 가능해 보임 — 정확한 - 구현은 미검증, 착수 시점에 확인 필요. +- **`target()`/Linker**: 위 정정대로 "이름 있는 자식 등록 + 시그널 중계"일 + 뿐이라, v2 쪽에서 굳이 흉내낼 이유가 약함 — v2엔 이미 Ref가 있고(컴포넌트 + 경계로 참조를 넘기는 표준 경로), 시그널 중계는 아래 4번 브리지 메커니즘이 + 흡수함. - **오브젝트 태그 조회**: v2엔 대응 개념이 아예 없음 — `CollectionService` 태그로 유사 구현은 가능하나 새 서브시스템에 가까워 "얇다"고 하기 애매. @@ -84,36 +92,91 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층 다수의 호환 플래그 + 성능 오버헤드 경고가 딸린 규모라 "얇다"고 부르기 어려움. -## 4. 결론 / 권장 +## 4. 사용자 제안 — v1/v2 병행 사용 + 경계 리졸브 브리지 (2026-08-06 후속, 유력 방향) -- **"v1 코드를 무수정으로 돌리는 완전 하위호환"은 사실상 불가능하거나, - 적어도 "얇은 래퍼"라는 표현에 맞지 않음** — 근본 원인은 API 이름 - 불일치가 아니라 컴포넌트 정체성 모델(OOP mutate vs 선언적 재구성) 충돌. -- **문법 설탕 수준의 부분 compat은 시도할 가치 있음** — 이벤트 self 관습, - 양방향 바인딩 헬퍼 정도는 opt-in 서브패키지로 저비용 제공 가능. 단 이건 - "v1 코드가 그대로 돈다"가 아니라 "v1에서 넘어온 사람이 익숙한 문법 - 일부를 v2 컴포넌트 함수 안에서 부분적으로 쓸 수 있다"는 수준. -- **"v1 코드베이스 자체를 옮기는 것"이 목표라면 compat보다 마이그레이션 - (자동 변환 스크립트 또는 수동 포팅 가이드)이 더 현실적** — `question.md`에 - 이미 있는 "v1 `objectListClass.__newIndex` 오타 기능 재현 테스트, 마이그레이션 - 가이드 작성 시점에 필요" 항목도 이 방향을 전제하고 있어 자연스럽게 합류 - 가능. +사용자가 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순위 방향이 그 문제 자체를 안 만들기 + 때문에 불필요. - 확정된 소스 트리(`base/architecture.md` "구현 착수" 절)는 `quad-base`/ - `quad-roblox` 두 패키지뿐 — `quad-compat`은 그 목록에 없음. 부분 - compat을 실제로 만든다면 세 번째 패키지를 새로 추가하는 결정이 필요함. + `quad-roblox` 두 패키지뿐 — 경계 브리지 글루 코드를 어디 둘지(별도 + `quad-compat` 패키지 신설 vs 필요한 프로젝트마다 로컬 유틸)는 아래 열린 + 질문. -## 5. 열린 질문 (사용자 판단 필요) +## 6. 열린 질문 (사용자 판단 필요) -- 목표가 "v1 문법 일부를 v2에서도 익숙하게 쓰게" 인지, "v1 코드를 무수정 - 또는 최소 수정으로 돌리는 것"인지 — 답에 따라 부분 compat vs 마이그레이션 - 가이드 중 방향이 갈림. -- 부분 compat으로 간다면 `quad-compat`을 정식 세 번째 서브패키지로 추가할지. -- 이벤트 self 관습을 compat에서 되살릴 때, `base/bind-system-plan.md`가 - 이미 명시한 반대 근거 4번(quad-debug 추적 밖 mutate 경로)을 어떻게 - 처리할지(quad-debug는 어차피 후순위라 지금 결정 불필요할 수도 있음). +- **양방향이 실제로 필요한가, 한쪽 방향(v2→v1, 데이터 새로 짜고 v1 UI는 + 유지)만으로 충분한가** — 사용자 예시는 v2→v1 한 방향. 반대 방향까지 + 필요하면 4번의 3번 항목(v1 시그널 구독 → v2 Source 갱신)을 실제로 + 설계해야 함. +- 브리지 글루 코드를 별도 패키지(`quad-compat`, 소스 트리에 아직 없음)로 + 뺄지, 아니면 정식 패키지 없이 "필요할 때 짜는 유틸 패턴" 정도로 문서화만 + 해둘지. +- v1 store의 `registerClass` 체이닝 기능(`:Tween`/`:Default` 등)까지 + 브리지가 흡수해야 하는 케이스가 있는지 — 단순 프로퍼티 재대입만으로 + 충분한 범위인지 실사용 예시로 확인 필요. +- (3-1을 실제로 병행 채택할 경우) 이벤트 self 관습을 compat에서 되살릴 때, + `base/bind-system-plan.md`가 명시한 반대 근거 4번(quad-debug 추적 밖 + mutate 경로)을 어떻게 처리할지 — quad-debug는 어차피 후순위라 지금 결정 + 불필요할 수도 있음. ## 착수 시점 지금 당장 설계/착수 불필요 — `CLAUDE.md` "지금 할 일" 1번(구현 착수, ROADMAP M0)이 최우선. 이 문서는 타당성 평가 결과만 남겨두고, 실제 설계는 -사용자가 방향(부분 compat vs 마이그레이션 가이드)을 정한 뒤 진행. +사용자가 방향(4번 병행+브리지의 세부 범위)을 정한 뒤 진행. From 3d9e48f5f1f8c038a86bff34b2c32a3e2ab2294a Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 20:39:32 +0900 Subject: [PATCH 17/62] =?UTF-8?q?v1-compat-plan.md:=20quad-roblox-v1-compa?= =?UTF-8?q?t=20=EA=B8=B0=EC=88=A0=20=EA=B3=84=ED=9A=8D=20=E2=80=94=20?= =?UTF-8?q?=EB=91=90=20=EC=9E=84=EB=B2=A0=EB=94=A9=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?+=20Slot=20=EB=AF=B8=EA=B2=B0=20=ED=95=AD=EB=AA=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정 사항 반영: 브리지는 v2→v1 단방향만, 패키지명 quad-roblox-v1-compat. v1 mount.lua/class.lua(Update 재렌더 시 __child 재부모, Clone 트리거 조건, cascading destroy 의존)와 v2 slot-plan.md(단일 마운트 소유권, retract=폐기, foreign Instance 처리 미명시)를 대조 조사해 두 임베딩 방향(v2 트리에 v1 리프 박기 / v1 트리 요소를 v2로 점진 교체)에 대한 구체적 안전 규칙을 도출. Slot이 quad 밖에서 만들어진 Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 결정 불가로 남겨 question.md에 교차 참조 추가. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 2 +- .claude/question.md | 23 +++--- .claude/research/v1-compat-plan.md | 123 ++++++++++++++++++++++++----- 3 files changed, 120 insertions(+), 28 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index 049bbdc..3131369 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -45,7 +45,7 @@ | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | UI 네이밍 컨벤션 문서 + Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 — 뼈대만, `debug-tooling-plan.md` 논의에서 파생 | 하 — 착수 시점 미정, 뼈대만 기록해둔 상태 | | `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | -| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 타당성 검토 — v1 런타임 재구현(문법 흉내)은 컴포넌트 정체성 모델 충돌로 얇게 안 됨, 대신 **v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`+v1 프로퍼티 재대입으로 값 리졸브해 넘기는 브리지**가 유력 방향으로 수렴(사용자 제안). quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — 브리지 세부 범위(양방향 필요 여부, 패키지 위치) 사용자 결정 대기 | +| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | ## 참고 diff --git a/.claude/question.md b/.claude/question.md index cbe7a1f..79af0cf 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -63,7 +63,11 @@ - **여러 Slot이 형제로 섞일 때 순서 보장** — `base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절 참고. Roblox 단일 백엔드로는 급하지 않음 (LayoutOrder/ZIndex로 대부분 해결), 다른 백엔드(웹 DOM 등) 재사용성과 - 직결되는 문제라 Slot 코어 로직 구현 시점에 재검토. + 직결되는 문제라 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에서 직접 실측 검증 완료 — 기술적 불확실성은 @@ -86,14 +90,15 @@ ui-shorthand-plan.md`. 기능 필요 여부(여전히 필요로 재확정)·메커니즘 (Handler)·패키지 배치(quad-roblox 코어)는 확정, 남은 건 이름 재검토 (용어 정리 합류)와 `RoundSize`(이미지 라운드) 대체 방식뿐. -- **v1 하위호환(compat) 레이어 방향** — `research/v1-compat-plan.md`(신규, - 2026-08-06, 같은 날 후속 논의로 수렴). v1 런타임을 v2 위에 재구현하는 - 건(문법 흉내) 컴포넌트 정체성 모델 충돌로 얇게 안 되지만, **v1을 그대로 - 두고 v2와 병행 실행 + 경계에서만 `state:Observer()`(lazy 포기, 항상 - 관측)로 값을 리졸브해 v1 프로퍼티에 재대입하는 브리지**는 유력 방향으로 - 수렴(사용자 제안, 기존 base 원시들만으로 조립 가능함을 확인). 남은 결정: - 이 브리지가 양방향(v1→v2도) 필요한지, 브리지 글루 코드를 별도 패키지로 - 뺄지. +- **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이 형제로 섞일 때 순서 보장" 항목과 같은 시점에 확인). ## 참고: 지금까지 확정된 것 (요약) diff --git a/.claude/research/v1-compat-plan.md b/.claude/research/v1-compat-plan.md index 082f173..6701149 100644 --- a/.claude/research/v1-compat-plan.md +++ b/.claude/research/v1-compat-plan.md @@ -153,30 +153,117 @@ v2 문법으로 흉내내는 작업 자체를 없앰** — v1 코드는 그냥 v - **"v1 코드를 완전히 무수정으로 v2 런타임 위에서 돌리는 것"(3-2, OOP mutate 재구현)은 여전히 비권장** — 1순위 방향이 그 문제 자체를 안 만들기 때문에 불필요. -- 확정된 소스 트리(`base/architecture.md` "구현 착수" 절)는 `quad-base`/ - `quad-roblox` 두 패키지뿐 — 경계 브리지 글루 코드를 어디 둘지(별도 - `quad-compat` 패키지 신설 vs 필요한 프로젝트마다 로컬 유틸)는 아래 열린 - 질문. -## 6. 열린 질문 (사용자 판단 필요) +## 6. 확정된 것 (2026-08-06 후속 라운드) -- **양방향이 실제로 필요한가, 한쪽 방향(v2→v1, 데이터 새로 짜고 v1 UI는 - 유지)만으로 충분한가** — 사용자 예시는 v2→v1 한 방향. 반대 방향까지 - 필요하면 4번의 3번 항목(v1 시그널 구독 → v2 Source 갱신)을 실제로 - 설계해야 함. -- 브리지 글루 코드를 별도 패키지(`quad-compat`, 소스 트리에 아직 없음)로 - 뺄지, 아니면 정식 패키지 없이 "필요할 때 짜는 유틸 패턴" 정도로 문서화만 - 해둘지. +- **방향: 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` 등)까지 브리지가 흡수해야 하는 케이스가 있는지 — 단순 프로퍼티 재대입만으로 충분한 범위인지 실사용 예시로 확인 필요. -- (3-1을 실제로 병행 채택할 경우) 이벤트 self 관습을 compat에서 되살릴 때, - `base/bind-system-plan.md`가 명시한 반대 근거 4번(quad-debug 추적 밖 - mutate 경로)을 어떻게 처리할지 — quad-debug는 어차피 후순위라 지금 결정 - 불필요할 수도 있음. +- (2순위 문법 설탕 어댑터를 실제 채택할 경우) 이벤트 self 관습을 compat에서 + 되살릴 때, `base/bind-system-plan.md`가 명시한 반대 근거 4번(quad-debug + 추적 밖 mutate 경로)을 어떻게 처리할지 — quad-debug는 어차피 후순위라 + 지금 결정 불필요할 수도 있음. ## 착수 시점 지금 당장 설계/착수 불필요 — `CLAUDE.md` "지금 할 일" 1번(구현 착수, -ROADMAP M0)이 최우선. 이 문서는 타당성 평가 결과만 남겨두고, 실제 설계는 -사용자가 방향(4번 병행+브리지의 세부 범위)을 정한 뒤 진행. +ROADMAP M0)이 최우선. 7-3의 Slot 관련 두 항목은 M0 이후 Slot 구현 +라운드에서 실제 코드와 함께 재확인해야 풀림 — 그 전까진 이 문서의 제안 +(7-1/7-2 규칙들)이 최선의 추정치. From 3af792e33a7711608eef24d750b2e25370add63e Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 21:26:58 +0900 Subject: [PATCH 18/62] =?UTF-8?q?research:=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=AF=B8=ED=8B=B0=EB=B8=8C=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=EC=84=B1=20=EC=A1=B0=EC=82=AC=20(=ED=82=A4=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EC=9E=AC?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹 프레임워크(React/Vue/Solid/Svelte/MobX) + Fusion/Vide/quad v1 소스 근거로 현재 확정된 프리미티브(Source/State/Store/Ref/Observer/Modifier/Slot/DI)만으로 충분한지 조사. 가장 명확한 빈 자리는 키 기반 동적 컬렉션 재조정(Fusion ForPairs/Vide indexes()류) — Slot은 CRUD 껍데기일 뿐 diff 엔진이 아님. Effect/cleanup 공개 API, Batch, Context는 부차적 후보로 확인. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 1 + .claude/question.md | 19 ++ .../research/additional-primitives-plan.md | 194 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 .claude/research/additional-primitives-plan.md diff --git a/.claude/README.md b/.claude/README.md index b37aa38..9a99848 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -45,6 +45,7 @@ | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | UI 네이밍 컨벤션 문서 + Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 — 뼈대만, `debug-tooling-plan.md` 논의에서 파생 | 하 — 착수 시점 미정, 뼈대만 기록해둔 상태 | | `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | +| `additional-primitives-plan.md` | 확정 프리미티브(Source/State/Store/Ref/Observer/Modifier/Slot/DI)만으로 충분한지 웹 프레임워크·Fusion/Vide/v1 소스 근거로 조사 — 키 기반 동적 컬렉션 재조정(Fusion `ForPairs`/Vide `indexes()`류)이 가장 명확한 빈 자리로 확인, Effect/Batch/Context는 부차적 후보 | 상 — 사용자 판단 대기, 착수 전 M0/M1 스코프에 영향 줄 수 있음 | ## 참고 diff --git a/.claude/question.md b/.claude/question.md index 5edd49c..4e8049d 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -10,6 +10,25 @@ ## 지금 열려있는 것 (우선순위순) +### 0. 추가 프리미티브 필요성 — 사용자 요청, 조사 완료(2026-08-06) + +사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 +같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 웹 프레임워크/ +Fusion/Vide/quad v1 소스를 서브에이전트 2개로 병렬 조사 완료, 상세는 +`research/additional-primitives-plan.md`. 요지: + +- **키 기반 동적 컬렉션 재조정(가장 시급)**: Fusion `ForPairs`/`ForKeys`/ + `ForValues`, Vide `indexes()`/`values()`, React `key` prop에 대응하는 + 프리미티브가 quad엔 전혀 없음 확인 — `Slot`은 CRUD 껍데기일 뿐 diff + 엔진이 아님. 인벤토리/리더보드/채팅로그 같은 실전 리스트 UI에 직결. + Slot 확장으로 갈지 별도 프리미티브(가칭 `Keyed`/`ForEach`)로 갈지부터 + 전혀 정해진 게 없음 — 사용자 판단 필요. +- Effect/Watch(자동 cleanup 공개 API), Batch/Transaction(이벤트 store-bind + churn 문제 직결), Context(트리 전파, 단 `purity-and-effects-plan.md` + 이식성 원칙과 상충)는 부차적 후보로 확인, 착수 여부 미정. +- Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 + 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단. + ### 1. 용어 정리 (사용자 요청, 진행 중) 사용자 원 메모: "quad는 register라던가 좀 부정확하거나 느낌이 바로 와닿지 diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md new file mode 100644 index 0000000..c65a5e6 --- /dev/null +++ b/.claude/research/additional-primitives-plan.md @@ -0,0 +1,194 @@ +# 추가 프리미티브 필요성 조사 — 다른 프레임워크 대비 갭 분석 + +**상태**: research — 조사 완료(2026-08-06), 사용자 판단 대기. 설계/구현 결정은 +전혀 안 됨, 이 문서는 "뭐가 빠졌을 수 있는지" 후보를 정리한 것뿐. + +## 배경 + +사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 +같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 지금까지 확정된 +독립 프리미티브(`Source`/`State`/`Store`/`Ref`/`Observer`/`Modifier`/`Slot`/ +`DI`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게 +재검토해달라는 요청. + +## 조사 방법 + +서브에이전트 2개를 병렬로 띄워 서로 다른 각도로 조사: +1. **웹 프레임워크 서베이** — React/Vue 3/Solid/Svelte 5/MobX 등 주류 + 반응형 프레임워크 기준, quad 프리미티브 집합에 빠진 개념이 있는지 일반 + 지식 기반 평가. +2. **Roblox 생태계 소스 기반 조사** — Fusion(`initreq/fusion/src`), + Vide(`initreq/vide/src`), quad v1(`initreq/quad/src`), PA님 실 프로덕션 + 코드(`initreq/artworks`)를 직접 읽고 파일:라인 근거로 검증. + +두 조사 모두 이미 있는 `research/framework-comparison-findings.md`(quad vs +Fusion/Vide/react-lua 강점/약점 비교)와는 다른 질문 — 그 문서는 "같은 +개념을 quad가 얼마나 잘 구현했는가"를 다뤘고, 이 문서는 "개념 자체가 +통째로 없는 게 있는가"를 다룸. + +## 결론 요약 + +| 후보 | 판정 | 심각도 | +|---|---|---| +| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리** | 높음 — 가장 시급 | +| Effect/Watch(자동 cleanup 포함 사이드이펙트) | 진짜 빈 자리 | 중간 | +| Batch/Transaction | 부분적 빈 자리(이미 문서화된 churn 문제와 직결) | 중~낮 | +| Context(트리 하위 암묵 전파) | 부분적 빈 자리, 철학과 상충 | 낮음 | +| Untrack/Peek | 빈 자리 아님 | - | +| Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | - | +| Error Boundary | 빈 자리 아님 | - | +| Readonly wrapper | 빈 자리 아님 | - | + +두 에이전트가 서로 독립적으로 **키 기반 리스트/컬렉션 재조정**을 가장 크고 +명확한 빈 자리로 지목했다는 점이 이 조사에서 가장 신뢰도 높은 결론. + +## 1. 키 기반 동적 컬렉션 재조정 — 진짜 빈 자리, 최우선 검토 대상 + +**무엇인가**: 데이터 배열(인벤토리, 리더보드, 채팅로그처럼 삽입/삭제/ +재정렬되는 목록)을 UI로 렌더링할 때, 이전 렌더 결과와 새 데이터를 +**정체성(key) 기준으로 diff**해서 변경분만 생성/갱신/파괴하는 프리미티브. +React `key` prop, Vue `v-for :key`, Solid ``가 이 층위. + +**Roblox 선례에 명확히 존재함**: +- Fusion `State/ForPairs.luau:46-89` — key/value 쌍마다 독립 `SubObject`를 + 만들어 안 바뀐 키는 재계산을 건너뜀. `ForKeys.luau`도 같은 `For` 코어 + 위에서 파라미터만 바꿔 변형. +- Vide `indexes.luau:11-119`, `values.luau:11-131` — 매 effect마다 + `scopes` 맵을 순회, 새 항목은 `branch()` 생성, 사라진 항목은 + `present(false)` 후 `destroy()`, 남은 항목은 값만 갱신(재생성 없음). + +**quad엔 없음(확인 완료)**: `base/slot-plan.md`의 `Slot`은 `add`/`remove`/ +`clear`/`get`/`set` CRUD를 지원하는 **뮤터블 배열**일 뿐, "입력 데이터를 +주면 알아서 diff해서 CRUD를 호출해주는" 계층이 없음. `base/*.md` 전체를 +grep해도 `ForPairs`/`ForKeys`/`keyed`/`diffing`류 언급 전무. quad v1에도 +`diff`/`reconcile`류 헬퍼는 없었음(grep 확인) — v1 사용자도 이 문제를 +프레임워크 밖에서 손으로 풀어왔다는 정황. + +**실전 영향**: 지금 구조로 동적 리스트를 만들려면 "이전 렌더된 항목을 +어딘가 들고 있다가 새 데이터와 직접 비교해 Slot의 add/remove를 손으로 +호출"하는 로직을 화면마다 재발명해야 함 — Fusion/Vide가 라이브러리 +차원에서 없애준 보일러플레이트(어떤 항목이 "같은 항목"인지, 순서가 바뀐 +항목을 삭제+재생성할지 in-place로 옮길지)가 quad엔 그대로 남음. Slot +자체는 "부모는 데이터 테이블만 다루면 됨"이라는 좋은 저수준 기반이라, +그 위에 diff 알고리즘 한 겹만 얹으면 되는 구조적으로 자연스러운 확장으로 +보임. + +**PA님 코드 정황 증거**: artworks엔 실제 UI 화면 코드가 없어(백엔드/OOP/ +데이터스토어 패턴 위주) 직접 증거는 못 찾았지만, `Utility/Array.luau`가 +`map`/`filter`/`insert`/`isEqual` 같은 범용 배열 유틸을 팀이 별도로 +만들어 쓰고 있었다는 점 자체가 "배열 반복 작업을 프리미티브로 뽑아내는" +습관이 있는 팀이라는 간접 방증. + +**열린 질문**: Slot의 확장 옵션으로 넣을지, 별도 최상위 프리미티브(가칭 +`Keyed`/`ForEach`/`List`)로 분리할지 — 설계 자체가 전혀 없는 상태. quad의 +"명시적 의존성" 철학(`:With`)과는 직교하는 문제라 `:With`/`:Compute` 확장이 +아니라 Slot 쪽 확장이 자연스러워 보인다는 게 조사 에이전트 소견이지만 +확정 아님. + +## 2. Effect/Watch(자동 cleanup 포함) — 진짜 빈 자리, 중간 심각도 + +React `useEffect`/Vue `watchEffect`/Solid `createEffect`는 "이펙트 +재실행 전/dispose 시 이전 cleanup을 프레임워크가 자동으로 불러준다"는 +계약을 가짐. quad `state:Observer(fn)`는 무효화 신호만 주고 cleanup 자동 +관리가 없음 — `fn`이 매번 뭔가를 새로 구독/생성한다면 이전 것을 정리하는 +책임이 전적으로 사용자 코드에 있음. + +`process`/`retract` 쌍이 정확히 이 문제(이전 처리를 무르고 새로 처리)를 +풀지만, 이건 base가 소유한 KV 핸들러 계층에 갇힌 내부 메커니즘이지 사용자가 +임의의 부수효과(`RunService.Heartbeat` 구독, 폴링 타이머 등)에 쓸 수 있는 +공개 API가 아님. 이미 증명된 내부 패턴을 사용자 레벨 `Effect(fn)`(반환값을 +다음 실행 전 cleanup으로 호출)로 얇게 노출하는 정도로, 큰 설계 변경 없이 +채울 수 있는 자리로 보임. + +*참고*: Fusion `Cleanup`/`doCleanup`, Vide `cleanup()`은 "명시적 dispose +콜백 등록 리스트" 모델이라 quad의 GC-native 철학과 정면 충돌하고 이미 +`base/comparison-fusion-vide.md`에서 반면교사로 다뤄짐 — 여기서 제안하는 +건 그것과 달리 *cleanup 콜백을 자동으로 호출해주는 것*(dispose 리스트 +직접 관리가 아님)이라 같은 문제가 아님, 혼동하지 말 것. + +## 3. Batch/Transaction — 부분적 빈 자리, 이미 문서화된 문제와 직결 + +quad의 push-invalidate/pull-recompute 모델은 batching을 상당 부분 공짜로 +줌 — `Get()`이 호출되기 전까진 `Set()`을 연달아 해도 재계산이 안 일어남. +다만 이미 문서에 자기진단된 예외가 있음: **store-bind 이벤트 핸들러는 +무효화 신호를 받는 즉시 pull**(`bind-system-plan.md` "자주 재계산되는 +State에 이벤트를 직접 물리면... churn 비용"). 즉 여러 Source가 `:With`로 +물린 파생 State에 store-bind 핸들러가 붙어 있으면, 여러 `Set()`을 순서대로 +실행하는 도중 중간 상태마다 핸들러가 여러 번 재실행되는 게 이미 확인된 +시나리오. `Batch(function() ... end)`류 opt-in 유틸은 이론적 완결성이 +아니라 **이미 확인된 실제 버그 클래스를 막는 것**이라 가치 있음 — 다만 새 +프리미티브라기보다 dispatch 엔진에 얹는 얇은 유틸 수준. + +Vide `batch.luau:4-21`가 정확히 이 역할(여러 `source:set()`을 하나의 +flush로 묶음). + +## 4. Context(트리 하위 암묵 전파) — 부분적 빈 자리, 철학과 상충 + +Fusion `Utility/Contextual.luau:28-88`(코루틴 스택 기반 스코프 값), Vide +`context.luau:14-72`(scope 그래프 조회)가 대응 개념. quad는 컴포넌트 +경계를 named parameter로만 넘기기로 이미 확정했고(`component-composition-plan.md`), +Context는 본질적으로 "명시적 전달을 건너뛰는" 도구라 quad의 명시성 철학과 +다소 충돌함. + +Roblox ModuleScript의 `require()` 캐싱이 사실상 싱글톤 전역 접근점 역할을 +자연스럽게 하므로(`local Theme = Store({...})`를 모듈로 export해서 아무 +컴포넌트에서나 require해 직접 읽으면 됨), **단일 게임 내부 UI 시나리오에선 +이게 사실상 Context 대체재로 충분**함 — quad가 이미 허용하는 "Store는 +부작용 허용" 철학과도 맞음. 다만 (a) 서브트리별 스코프 분리가 안 되고, +(b) `purity-and-effects-plan.md`의 이식성 원칙과 정면 충돌함(여러 게임에 +배포할 재사용 가능한 컴포넌트 라이브러리를 만들려는 순간, 테마 하나 +넘기려고 모든 중간 레이어에 `props.Theme`를 수동으로 계속 꿰어야 하는 +전형적 prop-drilling이 그대로 남음). "라이브러리로서의 지속 가능성"이라는 +프로젝트 목표와는 긴장 관계 — artworks에서 이걸 뒷받침할 "수동 전역 +context 테이블 전달" 패턴은 확인 못 함(급하지 않다는 방증). + +## 빈 자리 아닌 것으로 확인된 것들 + +- **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도 굳이 안 만들어도 된다"는 정황. + +## 제안 우선순위 (결정 아님, 검토 순서 제안) + +1. **키 기반 컬렉션 재조정** — 실전 영향이 가장 크고, 설계가 완전히 + 빈 상태라 가장 먼저 사용자와 상의할 가치. +2. **Effect 공개 API** — `process`/`retract`가 이미 증명한 패턴을 얇게 + 노출하는 정도라 구현 비용 낮음. +3. **Batch** — 이미 문서화된 churn 문제의 직접 해법, opt-in 유틸 수준. +4. **Context** — 급하지 않음(Roblox `require` 캐싱이 단일 게임 시나리오는 + 충분히 대체), 다만 "재사용 가능 컴포넌트 라이브러리"를 장기 목표로 + 본다면 재검토 가치 — `purity-and-effects-plan.md`와 같이 봐야 함. + +## 참고: 조사에 사용한 소스 근거 + +- 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//...` 기준(읽기 전용 참고 레포). From 4b839b09e193747b53ce7637b8d2acdb0c5e4a01 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 21:29:56 +0900 Subject: [PATCH 19/62] =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EC=A1=B0/quadnomicon=20=EC=8B=A0=EC=84=A4?= =?UTF-8?q?,=20=ED=94=84=EB=A0=88=EC=9E=84=EC=9B=8C=ED=81=AC=20=EC=A0=95?= =?UTF-8?q?=EC=A7=81=20=EB=B9=84=EA=B5=90,=20Source=EA=B0=80=20State?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=8C=EC=A1=B1=ED=95=98=EB=8A=94=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=ED=83=80=EC=9E=85=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 갈래 작업: 1. 문서 사이트 구조 확정(초심자/api/심화 3축 + quadnomicon 4번째 축) — research/documentation-plan.md 0번 항목, research/documentation-content-map.md 신설(초심자 core loop 목차 초안, 파일별 분류, 심화 에세이 후보 15개). 2. quad vs Fusion/Vide/react-lua 정직 비교 — research/framework-comparison-findings.md 신설. 3개 에이전트가 실제 소스(Fusion/Vide 로컬 클론)+웹 리서치(react-lua)로 검증. quad 강점(Slot 단일 마운트 가드, 열린 우선순위 축, 명시적 의존성, 다이아몬드 dedup)과 고칠 만한 약점 식별. 3. Source가 State를 구조적으로 만족하는 서브타입으로 재구성(핵심 변경) — store.key 타입 문제(레코드 타입 읽기/쓰기 비대칭)를 풀다가 StoreSource 프록시 설계(2026-08-04 확정분)를 완전히 대체: - Source가 State를 구조적으로 만족(단방향 호환), Store는 "이름 붙은 Source 모음"으로 단순화 — 별도 wrapper 생성/캐싱 불필요 - store.key = value(__newindex) 폐기 → store.key:Set(value) - Store:Emit(key) → source:Emit() - base/store-semantics.md에 새 절로 반영, bind-system-plan.md/ component-composition-plan.md/architecture.md 정정 - ROADMAP.md M0에 Luau 솔버 검증 항목 추가(재귀 타입 조합) - 폐기된 StoreSource 원문은 archive/store-source-proxy-reversed.md에 역전 이유·신구 비교와 함께 보존(quadnomicon 소재 후보) 전체 코퍼스 stale 참조 재점검: architecture.md 요약절, README.md 승격 누락, Modifier UB 규칙 확장 등 발견해서 수정. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 14 +- .../archive/store-source-proxy-reversed.md | 73 ++++++++ .claude/base/architecture.md | 23 ++- .claude/base/bind-system-plan.md | 83 +++++++-- .claude/base/component-composition-plan.md | 75 +++++--- .claude/base/store-semantics.md | 137 +++++++++++--- .claude/research/documentation-content-map.md | 174 ++++++++++++++++++ .claude/research/documentation-plan.md | 88 ++++++++- .../research/framework-comparison-findings.md | 110 +++++++++++ CLAUDE.md | 109 ++++++++++- ROADMAP.md | 6 + 11 files changed, 805 insertions(+), 87 deletions(-) create mode 100644 .claude/archive/store-source-proxy-reversed.md create mode 100644 .claude/research/documentation-content-map.md create mode 100644 .claude/research/framework-comparison-findings.md diff --git a/.claude/README.md b/.claude/README.md index b37aa38..5e23b71 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -12,7 +12,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | -| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 — 지금은 비어있음 | +| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용 — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음 | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | | `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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -34,6 +34,7 @@ | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정, getter 이름만 남음 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | +| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족(`StoreSource`/`RefSource` 중간안은 전부 폐기됨) — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐 [정정: 2026-08-04 승격됐으나 이 표에 반영이 안 돼있던 걸 2026-08-06 뒤늦게 수정] | ## `research/` — 아직 착수 전, 상의 필요 @@ -41,11 +42,18 @@ |---|---|---| | `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel | 중 — 세부 옵션만 남음 | | `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | -| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, `StoreSource` 프록시 — 핵심 골격 수렴, modifier/Ref가 컴포넌트 경계를 어떻게 통과하는지만 남음 | 상 — 사용자가 "가장 문제되는 부분"으로 직접 지목 | | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | -| `documentation-plan.md` | UI 네이밍 컨벤션 문서 + Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 — 뼈대만, `debug-tooling-plan.md` 논의에서 파생 | 하 — 착수 시점 미정, 뼈대만 기록해둔 상태 | +| `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개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 | | `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | +## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 + +| 문서 | 내용 | +|---|---| +| `store-source-proxy-reversed.md` | 2026-08-04에 확정했던 `StoreSource` 프록시 설계(Store가 Source를 감춘 별도 프록시로 감쌈) — 2026-08-06 세 번째 세션에서 "Source가 State를 구조적으로 만족" 재구성으로 완전히 대체됨. 원문·역전 이유·신구 비교표 보존, `quadnomicon` 소재 후보 | + ## 참고 - **저장소 소유자가 답해야 할 질문 전체 취합**: `.claude/question.md` diff --git a/.claude/archive/store-source-proxy-reversed.md b/.claude/archive/store-source-proxy-reversed.md new file mode 100644 index 0000000..41ca8d4 --- /dev/null +++ b/.claude/archive/store-source-proxy-reversed.md @@ -0,0 +1,73 @@ +# [역전됨] `StoreSource` 프록시 설계 — Source가 State를 만족하는 서브타입으로 대체됨 + +**역전 일시**: 2026-08-06 (세 번째 세션). **원 확정 일시**: 2026-08-04 +(`component-composition-plan.md` 컴포넌트화 논의 3~4번 항목). +**현재 유효한 설계**: `base/store-semantics.md`의 "Source가 State를 +만족함 — 구조적 서브타입" 절이 최종 소스. 이 파일은 더 이상 능동적으로 +참고할 필요 없음(구현에 안 씀) — 왜 이 경로를 갔다가 되돌아왔는지가 +`quadnomicon`(프레임워크 설계자용 심화 콘텐츠, `research/documentation-plan.md` +0번 항목) 소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 (2026-08-04 원문) + +`store.key`로 값을 얻을 때, Store가 내부 Source 객체를 **직접 노출하지 +않고** 별도의 얇은 프록시 타입 `StoreSource`를 만들어 반환하는 설계였음: + +> **Source = 인터페이스이자 구현체**: 독립 생성자 `Source(initial)`가 기본 +> 구현체, `store:GetSource("key")`(가칭)류 접근자가 반환하는 값은 같은 +> 인터페이스를 구현하는 별도의 얇은 프록시(`StoreSource`) — 읽기는 +> `store.key`로, 쓰기는 `store.key = v`로 위임. **내부 Source 객체를 그대로 +> 노출하지 않음** — 그러면 "쓰기는 오직 Store의 `__newindex`뿐"이라는 기존 +> 확정과 새 쓰기 경로가 충돌하게 됨. +> +> **캐시 안 함**: State가 이미 "매번 새로 만듦, store에 캐시 안 됨"으로 +> 확정돼 있어 일관성 + 엔지니어링 비용 둘 다 이쪽이 쌈 — **사용자 확정** +> ("그냥 엔지니어링적으로 비용이 싼거 택해"). + +같은 논의에서 파생된 핸들러 계약 쪽 결정도 같이 뒤집힘 — Source를 핸들러가 +직접 받을 때는 별도 유니온 타입으로 처리하기로 했었음: + +> 핸들러가 값을 받을 때 `Source | State` 유니온으로 받고, 내부에서 +> 타입 체크만 하면 됨(Source면 인스턴스 변경 이벤트에 걸어 역방향 쓰기까지 +> 처리, State면 읽기만) — `isHandlable`/`priority`/`process`/`retract` 4종 +> 계약에 5번째 항목을 추가할 필요 없음. + +## 역전된 이유 + +`store.key`의 타입 문제를 다시 들여다보다가 드러남: Store의 정적 타입을 +`{key: State}`류 평범한 레코드 타입으로 지으면(2026-08-04 3차 +라운드에서 확정했던 방식) Luau 구조적 타이핑상 그 필드의 읽기/쓰기 타입이 +같아야 하는데, 실제 쓰기(`store.key = value`, raw `number`)와 읽기 +(`State`)가 서로 다른 타입이라 애초부터 정합적이지 않았음 — +`StoreSource` 프록시 설계 시점엔 이 비대칭을 못 잡았던 것. + +이걸 풀려고 대안(store를 `store.key`/`store.state.key`로 네임스페이스 +분리하는 안, `RefSource`라는 store 전용 타입을 새로 만드는 안)을 +검토하다가, 더 근본적인 재구성으로 수렴: **Source 자체가 구조적으로 +State를 만족**(Svelte `Writable extends Readable`와 같은 모양) +하게 만들면,애초에 "Source를 감추고 별도 프록시로 감쌀" 이유 자체가 +없어짐 — Store가 내부에 갖고 있는 진짜 Source 객체를 그대로 돌려줘도 +안전하고, 오히려 프록시 객체를 매번 만들거나 캐싱하는 계층 하나가 통째로 +사라져서 더 쌈. + +## 이전 것과 지금 것의 차이 + +| | `StoreSource`(역전됨) | Source가 State를 만족(현재) | +|---|---|---| +| `store.key`가 반환하는 것 | 별도 프록시 `StoreSource`(Source 인터페이스를 구현한 wrapper) | 진짜 `Source` 객체 그대로 | +| 쓰기 문법 | `store.key = value`(`__newindex`) | `store.key:Set(value)` | +| 캐싱 | "매번 새로 만듦"(State와 같은 정책) | Store 생성 시 이미 만들어둔 Source를 그대로 반환 — 별도 캐싱 메커니즘 자체가 불필요 | +| 핸들러가 Source를 받는 방법 | `Source \| State` 명시적 유니온 | `State` 하나만 받아도 서브타입 호환으로 자동 통과, 런타임에 구분하고 싶으면 `isSource`류 판별자 | +| 타입 정합성 | 레코드 필드 읽기/쓰기 타입 비대칭 문제가 잠재해 있었음(발견 안 된 채로 확정됐었음) | 필드 타입이 항상 `Source`로 대칭 — 쓰기가 메소드 호출로 옮겨가며 문제 자체가 해소됨 | + +## 왜 완전히 헛수고는 아니었나 + +`StoreSource`가 짚었던 문제의식(Store 내부 표현을 그대로 노출하면 안 +될 수 있다, Source와 State는 다른 쓰기 권한을 가져야 한다)은 여전히 +유효함 — "State/Source 경계 규칙: 파생이면 읽기전용, 원본이면 쓰기 가능" +원칙(`base/component-composition-plan.md` 2번)은 살아남았고, 결론만 +"별도 프록시 타입을 만든다"에서 "Source 자체를 서브타입으로 승격한다"로 +바뀐 것. 이 반전 자체가 "타입이 없던 v1 습관을 재검토 없이 typed 재작성에 +그대로 가져오면 안 된다"는 더 큰 교훈의 구체적 사례이기도 함(사용자 회고, +`base/store-semantics.md` 참고) — `quadnomicon`에서 "설계가 왜 이렇게 +반전됐는가" 사례로 쓰기 좋음. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 791b021..c21205f 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -190,15 +190,20 @@ CollectionService 태그 등)를 흉내낼 필요가 없고, quad-base 자체 ## Store/State/Source 온톨로지 — 확정됨 (요약) -Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 접근할 -때마다 그 source를 감싸는 새 State(자기 고유 value 없는 조합 가능한 캐시)를 -반환한다. 전파는 push-invalidate(신호만)/pull-recompute(`Get()` 시점) — -Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림. State는 -쓰기 대상이 아니고(값 쓰기는 항상 Store의 `__newindex`), 값 하나만 다룰 땐 -Store와 별개인 가벼운 `Source` 프리미티브를 씀. `store.key` dot-access를 타입 -추론 1급 경로로 삼는 것도 3차 라운드에서 정식 확정됨 — **더 이상 열린 질문 -아님**, 남은 건 정확한 API 이름뿐. 상세는 `base/bind-system-plan.md`의 -"Store/State/Source 온톨로지" 절 참고. +Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 +접근하면 Store 생성 시 이미 만들어둔 그 Source 객체를 그대로 반환한다 +(별도 wrapper 없음 — **[2026-08-06 후속 세션 정정]** 원래 "매번 새 State를 +감싸 반환"이었으나, `Source`가 구조적으로 `State`를 만족하도록 재구성되며 +wrapper 계층 자체가 불필요해짐). 전파는 push-invalidate(신호만)/ +pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 +의존성 중복 재계산 문제가 풀림. State는 쓰기 대상이 아니고, 값을 쓰는 +경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — +`.value`/`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가). 값 하나만 +다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀. +`store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서 +정식 확정됨 — **더 이상 열린 질문 아님**, 남은 건 정확한 API 이름뿐. +상세는 `base/store-semantics.md`의 "Source가 State를 만족함" 절과 +`base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. ## 아직 미정 (research/로 분리됨) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index b7c280a..300076f 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -528,11 +528,16 @@ read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최 생성자 이름뿐(구현 단계). `base/store-semantics.md`의 "State 프리미티브는 실제로 필요하다" 정정에서 이어짐. -**핵심 온톨로지** (변경 없음): +**핵심 온톨로지** (2026-08-06 후속 세션에서 Store/Source 부분 정정 — +아래 "State는 쓰기 대상이 아님" 절 이후 내용 및 `base/store-semantics.md`의 +"Source가 State를 만족함" 절 참고): - **Source** — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). -- **Store** — source들의 집합체. `store.a`처럼 키로 접근하면 그 source를 - 감싼 **새 State**를 매번 만들어 반환(state가 store에 캐시되어 재사용되는 - 게 아님 — source만 store에 귀속된 유일한 실체). + **구조적으로 State를 만족(단방향 호환)** — `.value`/`:Get()`/`:With`/`:Compute` + 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. +- **Store** — Source들의 이름 붙은 모음, 그 이상 아님. `store.a`처럼 키로 + 접근하면 Store 생성 시 이미 만들어둔 **그 Source를 그대로 반환**(더 이상 + 별도 State wrapper를 매번 만들거나 따로 캐싱하지 않음 — Source 자체가 + 이미 State를 만족하므로 wrapper 계층 자체가 불필요해짐). - **State** — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 독립적 value 개념이 없음. `state(state)`로 기존 state의 결과를 받아 새 state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 @@ -574,6 +579,41 @@ Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 체이닝")과 이 원칙이 충돌하지 않는 이유가 바로 이것 — clone은 그저 참조 복사라 State 필드는 클론 이후에도 여전히 살아있는 lazy 핸들로 남음. +**왜 State 체인을 Modifier처럼 플래튼하지 않는가 (2026-08-06 후속 세션)** + +**문제 제기(사용자)**: State가 `a → b → c`처럼 계속 연결되는 구조면, 이전 +노드가 다음 노드에 대한 emit 연결/값 연결을 항상 들고 있어야 함(weak +table로 GC는 되지만 별도 데이터스트럭처 관리 부담). 대안으로, 각 State가 +자기 Compute 함수 목록을 통째로 누적해서 갖고(Modifier의 clone-then-return +체이닝처럼) 매번 클론+append하면 링크드 그래프 자체가 필요 없어지지 +않는가? + +**기각 이유 — State의 정의 자체가 "캐싱하는 존재"임.** 위 온톨로지에 +"State — source(또는 다른 state)의 결과를 **캐싱만 하는** 존재"라고 +확정돼 있고, `previous` 두 번째 인자 메커니즘(무거운 파생 엔진 객체 +재생성 비용 절감)도 이 캐싱 전제 위에서만 의미가 있음. 만약 Compute +체인을 매번 통째로 클론해 각 leaf가 독립된 함수 목록을 갖게 하면, 중간 +State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b:Compute(g1)`, +`c2 = b:Compute(g2)`로 분기)에서 `b`까지의 계산이 캐시 공유 없이 소비자 +수만큼 중복 실행됨 — `previous` 메커니즘이 막으려던 문제를 반대로 다시 +만들어내는 셈이라 방향이 안 맞음. + +**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야 +실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건 +실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는 +State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 워크를 막는 +`invalid` 플래그 dedup 장치도 체인 전체가 링크드일 것을 요구하지 않고 +각 노드가 자기 구독자 목록만 가지면 되는 것이라, 이 결정과 무관하게 +그대로 유지됨. 구현은 Observer와 동일한 패턴(외부 weak table, +`{[child] = true}` 류)으로 충분 — 새 메커니즘 발명 아님. + +**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 +플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 +것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음. +`research/documentation-plan.md`의 심화 문서 후보로 남겨둠 — "왜 State는 +Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사용자를 위한 +좋은 심화 콘텐츠 소재. + **`:With`/`:Compute` — self 인자도 lazy 핸들로 통일** - 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 @@ -595,11 +635,17 @@ Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 **State는 쓰기 대상이 아님 — 확정, Source는 독립 공개 프리미티브로 격상** -- `.value`는 항상 읽기 전용. 값을 쓰는 경로는 오직 Store의 `__newindex` - (`store.key = value`, 이미 확정된 문법)뿐 — State에는 대응하는 쓰기 API가 - 아예 없음. "State에 `.value = x`를 허용하면 다른 source에서 파생된 - state에 직접 쓰기가 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 - 해소(그런 API 자체가 없음). +- `.value`는 항상 읽기 전용. State에는 쓰기 API가 아예 없음. "State에 + `.value = x`를 허용하면 다른 source에서 파생된 state에 직접 쓰기가 + 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가 + 없음). +- **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value` + (`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — 이유와 + 상세는 `base/store-semantics.md`의 "Store 값 설정 문법" 절 참고(요지: + Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을 + 맞추려면 대입 문법을 포기해야 함 + `=`가 암시하는 "즉시 커밋"이 실제 + lazy 동작과 정서적으로 안 맞는다는 논거). 같은 문서의 "Source가 State를 + 만족함" 절에 Source/State 서브타입 구조 전체가 정리돼 있음. - **`Source`는 Store의 내부 구현 디테일이 아니라 별도의 가벼운 공개 프리미티브로 노출** — Store는 다수의 source를 등록/관리하는 무거운 구조라, 값 하나만 반응형으로 다루고 싶을 때 Store를 통째로 만드는 건 @@ -632,10 +678,15 @@ Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 - `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정** - — Store 타입을 `{key: State, other: State}`류 평범한 + — Store 타입을 `{key: Source, other: Source}`류 평범한 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 - 리터럴 narrowing 문제 자체가 안 생김. `store "key"` 문자열 커링은 동적 - 키가 필요할 때 쓰는 미타입(`State`) 폴백으로 격하. + 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State` + 필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source`로 + 갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가 + 더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 + 해소됨, `base/store-semantics.md` "Source가 State를 만족함" 절 참고). + `store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source`) + 폴백으로 격하. - 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). @@ -821,9 +872,11 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 **Store 쪽 dot-access는 그대로 유지**: `store.key`(1급 타입 경로)/ `store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가 -타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: State, ...}`류 -평범한 레코드 타입으로 지어짐) 그대로 유지 — 이벤트만 예외였을 뿐, "정적으로 -알려진 것=필드 접근" 원칙 자체가 깨진 건 아님. +타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source, ...}`류 +평범한 레코드 타입으로 지어짐[정정: 2026-08-06 후속 세션에서 필드 타입이 +`State`→`Source`로 갱신, "Source가 State를 만족함" 절 참고]) 그대로 +유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가 +깨진 건 아님. **PA님 코드와 대조해서 재확인한 것(변경 없음)**: - **OOP 회피 결정은 오히려 보강됨** — PA님의 `ObjectOrientedProgramming/ diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 4690c10..e9d1c2c 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -52,33 +52,50 @@ Vue/Svelte 대비 내세우는 강점과 동일 논리) — **사용자 확정** ### 2. State/Source 경계 규칙: 파생이면 읽기전용, 원본이면 쓰기 가능 State는 `:With`/`:Compute`로 만들어진 파생값일 수 있어 쓰기가 정의 자체가 -안 됨. Source(독립이든 Store 소속 `StoreSource` 프록시든)는 파생이 아니라 -항상 원본 슬롯 하나를 직접 가리키므로 쓰기가 의미 있음 — **사용자 확정** -("맞음. 확실해"). +안 됨. Source는 파생이 아니라 항상 원본 슬롯 하나를 직접 가리키므로 쓰기가 +의미 있음 — **사용자 확정**("맞음. 확실해"). 이 원칙 자체는 그대로 유지되고, +아래 3번의 구체적 메커니즘만 2026-08-06 후속 세션에서 더 단순하게 갱신됨. -### 3. `StoreSource`: Source를 인터페이스+구현체로 두고, Store 키에서 그 인터페이스를 구현하는 얇은 프록시를 받음 +### 3. [정정, 2026-08-06 후속 세션] `StoreSource` 프록시 개념 폐기 — Source가 State를 구조적으로 만족하므로 Store가 내부 Source를 그대로 반환 -- **Source = 인터페이스이자 구현체**: 독립 생성자 `Source(initial)`가 기본 - 구현체, `store:GetSource("key")`(가칭)류 접근자가 반환하는 값은 같은 - 인터페이스를 구현하는 별도의 얇은 프록시(`StoreSource`) — 읽기는 - `store.key`로, 쓰기는 `store.key = v`로 위임. **내부 Source 객체를 그대로 - 노출하지 않음** — 그러면 "쓰기는 오직 Store의 `__newindex`뿐"이라는 기존 - 확정과 새 쓰기 경로가 충돌하게 됨. -- **캐시 안 함**: State가 이미 "매번 새로 만듦, store에 캐시 안 됨"으로 - 확정돼 있어 일관성 + 엔지니어링 비용 둘 다 이쪽이 쌈 — **사용자 확정** - ("그냥 엔지니어링적으로 비용이 싼거 택해"). +**원래 이 절은 "Source를 인터페이스+구현체로 두고 Store 키에서 얇은 프록시 +(`StoreSource`)를 받는다"는 방향이었음 — 지금은 폐기됨.** 이후 세션에서 +Store/Source dot-access 타입 문제(레코드 타입의 읽기/쓰기 비대칭)를 +다루다가 더 근본적인 재구성으로 수렴: **`Source`가 구조적으로 +`State`를 만족**(단방향 호환, Svelte `Writable extends Readable`와 +같은 모양)하도록 만들면, Store가 "내부 Source를 감추고 별도 프록시를 +새로 만들어 노출"할 이유 자체가 없어짐 — `store.key`가 Store 생성 시 +이미 만들어둔 진짜 Source 객체를 그대로 돌려줘도 안전함(Source 자체가 +이미 State의 읽기 계약을 전부 만족하고, 거기에 `:Set(value)`/`:Emit()`이 +추가로 있을 뿐이라 "원본이라 쓰기 가능"이라는 위 2번 규칙과도 자연히 +맞아떨어짐). 상세 근거·타입 설계·Luau 솔버 검증 필요 항목은 +`base/store-semantics.md`의 "Source가 State를 만족함" 절이 최종 소스 — +이 문서는 배경만 유지. -### 4. Source 직접 전달(양방향)은 핸들러 계약 확장 없이 타입 유니온으로 처리 — 단, 실사용 범위는 좁음 +- **쓰기 문법도 같이 바뀜**: `store.key = v`가 아니라 `store.key:Set(v)` + (레코드 타입 읽기/쓰기 대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 + 안 맞는다는 논거, 같은 절 참고). +- **캐시 문제도 이걸로 자연히 해소**: State를 "매번 새로 만듦"이던 이전 + 모델과 달리, 이제 Store는 생성 시 만들어둔 Source를 그대로 갖고 있다가 + 돌려주기만 하므로 별도 캐싱 메커니즘 자체가 불필요(래퍼 생성 단계가 + 아예 없어짐 — 이전보다 더 쌈). -- 핸들러가 값을 받을 때 `Source | State` 유니온으로 받고, 내부에서 - 타입 체크만 하면 됨(Source면 인스턴스 변경 이벤트에 걸어 역방향 쓰기까지 - 처리, State면 읽기만) — `isHandlable`/`priority`/`process`/`retract` 4종 - 계약에 5번째 항목을 추가할 필요 없음. Source 자체가 계산이 없는 원천이라 - 가능한 단순화 — **사용자 확정**("그냥 타입 상 source를 받거나 state를 - 받거나 하면 됨. source 자체는 원천이라 컴퓨팅 같은거 없어"). -- **하지만 실사용은 좁을 것으로 예상**: `isEnabled`처럼 여러 조건에 영향 - 받는(=파생된) 값은 애초에 State지 Source가 아니므로 이 경로로 못 넘김. - 즉 Source 직접 전달이 통하는 건 진짜 단순한 1:1 원본-토글 케이스뿐이고, +### 4. [정정, 2026-08-06 후속 세션] Source 직접 전달 — 타입 유니온도 불필요해짐 + +원래 "핸들러가 `Source | State` 유니온으로 받는다"는 방향이었으나, +Source가 State를 구조적으로 만족하는 지금은 **유니온 자체가 필요 없음** — +핸들러는 그냥 `State` 하나만 받아도 Source 인스턴스가 자동으로 그 +자리에 들어감(서브타입 호환). `isHandlable`/`priority`/`process`/`retract` +4종 계약에 5번째 항목을 추가할 필요 없다는 결론은 그대로 유지, 다만 근거가 +"타입 유니온으로 처리"에서 "서브타입이라 유니온 자체가 불필요"로 더 +단순해짐. 단, 핸들러가 "이거 Source면 역방향 쓰기까지 걸고 싶다"처럼 +**런타임에** Source인지 구분하고 싶은 경우는 여전히 있을 수 있음 — +그건 타입 유니온이 아니라 런타임 판별자(`isSource`류, `isObserver` +패턴과 동일한 결)로 처리하면 됨. + +- **실사용 범위가 좁다는 판단은 그대로 유지**: `isEnabled`처럼 여러 조건에 + 영향받는(=파생된) 값은 애초에 State지 Source가 아니므로 이 경로로 못 + 넘김. Source 직접 전달이 통하는 건 진짜 단순한 1:1 원본-토글 케이스뿐이고, 일반적인 경우엔 React식 `value(State) + onChange(callback)` 패턴이 기본 — **사용자 확정**("isenabled가 여러 조건에 영향 받으면 바로 문제가 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 @@ -250,10 +267,12 @@ Ref를 받으면 그냥 전부 실행하면 됨, `modifier-plan.md` §4-2) — ## 남은 열린 질문 (`.claude/question.md`에도 취합, 전부 후순위 — 이름만 남음) - **정확한 API 이름**: `Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 - 자체도 불확실 — 아마 불필요), `GetSource` 계열 접근자 이름, `Source` - 독립 생성자 이름, 컴포넌트 경계용 `props.Modifier`/`props.Ref` 필드명, - `Modifier.Merge` 함수명은 전부 가칭. `base/bind-system-plan.md`의 "남은 - 열린 질문" 절(정확한 함수/생성자 이름 미정)과 같은 급의 후순위 항목 — - 구현 단계에서 다른 이름들과 함께 확정. + 자체도 불확실 — 아마 불필요), `Source`/`State` 독립 생성자·타입 이름, + 컴포넌트 경계용 `props.Modifier`/`props.Ref` 필드명, `Modifier.Merge` + 함수명은 전부 가칭. (`GetSource` 계열 접근자는 위 3번 정정으로 아예 + 불필요해짐 — `store.key`가 직접 Source를 반환하므로 별도 접근자 자체가 + 없음.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/ + 생성자 이름 미정)과 같은 급의 후순위 항목 — 구현 단계에서 다른 + 이름들과 함께 확정. - **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와 무관. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index f76f7b5..3ff65d9 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -54,9 +54,11 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 해주지만, 그 결과를 다시 분기하고 싶으면(하나의 파생 스트림에서 여러 소비자가 각자 다른 추가 compute를 얹고 싶은 경우) `state(state)`처럼 기존 state의 결과를 받아 새 state를 만드는 조합이 필요. -- **store에서 state를 얻는 연산(예: `store "key"`)은 항상 새 state 인스턴스를 - 반환한다** — state 자체가 캐시되어 재사용되는 게 아니라, source만 store에 - 귀속된 유일한 실체이고 그 위의 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)`로 분기하면 될 걸로 보임 @@ -93,29 +95,115 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 추론 문제는 `store.key`(dot-access)를 1급 경로로 확정하며 해소(같은 문서 "타입 추론 문제" 절, 3차 라운드). -## Store 값 설정 문법 — v1 인체공학 유지 (확정) +## Source가 State를 만족함 — 구조적 서브타입, RefSource 개념 폐기 (2026-08-06 후속 세션) -**사용자 확인 완료**: Store 값 설정은 `__newindex` 기반(`myStore.key = value`)을 -그대로 유지 — ProfileService 등 Roblox 생태계에서 이미 익숙한 관용구라 바꿀 -이유 없음. 마찬가지로 다음 두 인체공학도 유지: +**배경**: `store.key`가 매번 새 State를 감싸 반환하던 이전 모델의 타입 +문제(레코드 타입 `{key: State}`가 읽기/쓰기 비대칭이라 Luau +타이핑이 안 맞음, 위 "Source가 State를 만족함" 논의에서 도출)를 풀다가 +사용자가 제안한 더 근본적인 재구성. `RefSource`(store 슬롯을 가리키는 +전용 타입)를 따로 만드는 중간안도 검토했으나, 최종적으로 **Source 자체가 +State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하는 쪽으로 +수렴. -- **괄호 생략(paren-less) 구조** — 필요 시 커링(`myStore "key"`처럼 문자열 - 하나로 register를 얻는 v1 스타일)을 계속 허용. -- **`:` 체이닝** — 값을 바꾸는 연산에 한해 체이닝 문법 허용(`base/ - architecture.md`의 "함수지향 디폴트, `:`는 예외적으로만" 원칙과 일치 — 체이닝이 - 자연스러운 곳 중 하나가 바로 이 store 값 변경). +**확정 방향**: +- **`Source`가 구조적으로 `State`를 만족(단방향 호환)** — State + 자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable extends + Readable`와 같은 모양). Source는 State가 주는 모든 것(`.value`, + `:Get()`, `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 + 추가로 가짐. +- **`:With`/`:Compute`는 Source에서도 항상 `State`를 반환** — Source + 자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는 + 것과 동치. 구현은 metatable `__index` 델리게이션(Source의 메소드 + 테이블이 State의 메소드 테이블로 폴백)으로 충분 — `Modifier`의 제네릭 + `__index` 트릭(`base/modifier-plan.md`)과 같은 패턴이라 로직 중복이 + 생기지 않음. +- **`RefSource` 같은 별도 타입은 불필요, `Store({defaults})`가 + 내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨** — `store.key`는 + Store 생성 시 이미 만들어둔 Source를 그대로 돌려줄 뿐, 매번 새로 만들거나 + 별도로 캐싱할 wrapper 객체 자체가 없음. 이전에 검토했던 "State를 + weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/캐싱 단계 자체가 + 사라짐). v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 + 없어 단순하게 쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 + 타입이 핵심 제약이라 그 전제 자체가 더 이상 안 맞고, 이번 정리로 Store는 + "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. +- **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는 + 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 + 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 + 구현 델리게이션 포함)이라 그 금지와 충돌하지 않음. +- **동적 키 폴백(`store "key"`)은 이제 `State`가 아니라 `Source`를 + 반환**하는 것으로 자연히 갱신됨(위 "타입 추론 문제" 절과 연동). -`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 함께 읽을 -것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 "불변 빌더"를 -흉내낸 것이었지, `:` 체이닝 문법 자체나 커링 문법 자체가 아니었음. v2는 문법 -인체공학(사용자가 좋아하는 부분)은 유지하되 내부 구현(체이닝이 아니라 팩토리 -함수)만 바꾼다. +**검증 필요(확정 아님, M0 스파이크 대상)**: `Source`의 `:Compute` +시그니처가 자기 자신(`Source`)과 `State`를 동시에 참조하는 제네릭 +메소드라, Luau 솔버가 재귀 타입 조합에서 막히지 않는지 실제로 검증 +필요(사용자 우려: "솔버가 종종 죽는다"). 구분해서 볼 것: +- **자기 자신을 가리키는 self 타이핑**(`{ Compute: (self: Source, ...) -> State }` + 같은 패턴)은 Luau에서 극히 흔하고 대체로 안전 — 모든 메소드 테이블 + 클래스가 쓰는 패턴이라 이것 자체가 위험 신호는 아님. +- **진짜 위험한 건 두 제네릭 타입 별칭이 서로를 참조하는 상호 재귀** + (`Source` 정의가 `State`를 참조하고, `State`도 거꾸로 + `Source`를 참조하는 경우) — 이게 Luau 솔버가 알려진 대로 취약한 + 패턴. **`State`가 `Source`를 전혀 참조하지 않도록 먼저 독립적으로 + 정의하고, `Source`만 `State`를 참조하는 단방향 의존으로 두면** + 이 위험한 패턴 자체를 피할 수 있어 보임 — 다만 이것도 추론이라 실제 + Luau로 확인 전엔 확정 아님. +- 사용자는 `&`(교차 타입) 조합보다 **타입을 손으로 펼쳐 쓰는(flatten) + 쪽을 선호**(엔지니어링 비용을 감수하더라도 솔버 안정성 우선) — 이건 + 런타임 구현의 델리게이션(위 항목)과는 별개 축이라 서로 충돌 안 함: + 타입은 펼쳐 쓰고 구현은 공유하는 조합이 가능함. +- `ROADMAP.md` M0의 "Store/State propagation" 스파이크 항목에 이 구체적 + 케이스(Source가 State를 만족하는 제네릭 메소드 체이닝)를 포함해서 + 검증할 것. -## Store 값을 직접 mutate한 뒤 전파 — `:Emit(key)` (2026-08-06 후속 세션) +**이름 주의**: `Source`/`State`라는 이름 자체가 `CLAUDE.md` "지금 할 일" +2번의 용어 정리 대상(특히 `State`)과 겹침 — 구조(서브타입 관계, RefSource +폐기)는 지금 확정해도 정확한 이름은 용어 정리 라운드까지 가칭으로 남김. + +## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정) + +**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value` +그대로")은 폐기됨.** 아래 "Source가 State를 만족함" 절의 타입 설계와 +맞물려 재검토된 결과: + +1. **타입 대칭성**: `store.key`가 이제 `Source`를 직접 반환하는 + 평범한 레코드 필드(`{key: Source}`)로 타이핑되는데, 레코드 + 필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함. + `store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source`)/쓰기(`T`) + 타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드 + 타입이 항상 `Source`로 대칭적이라 문제 자체가 안 생김(사용자 지적). +2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는 + 부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy** — + `Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할 + 때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜 + 계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드 + 호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게 + 신호함(사용자 확정 논거). +3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/ + architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게 + 아니라 기존 원칙의 정상적인 적용. + +**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키 +전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지. +`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지. + +`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 함께 +읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 +"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 커링 문법 자체가 +아니었음. + +## Source 값을 직접 mutate한 뒤 전파 — `:Emit()` (2026-08-06 후속 세션, 호출부 정정) **결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서 -mutate한 뒤, `Store:Emit(key)`로 그 key의 무효화 신호만 별도로 쏘는 -것을 **Source 원천(store가 직접 들고 있는 값)에 한해 허용**한다. +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나 외부 라이브러리 @@ -153,8 +241,13 @@ mutation에 정식 신호를 붙여주는 것뿐. 정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고 (`base/modifier-plan.md`), `State`가 UB로 확정된 것도 같은 이유(`modifier-plan.md` 7번) — Store/State/dispatch 경로엔 애초에 -Modifier용 processor가 없음. 위 "하드 경계"와 같은 이유로, `Emit`이 -Modifier의 정적 flatten과 충돌할 걱정 자체가 성립하지 않음(둘이 만날 +Modifier용 processor가 없음. **[2026-08-06 후속 세션 추가]** Source가 +State를 구조적으로 만족하게 되면서 이 UB는 `Source`(Store를 +거치지 않는 독립 `Source(someModifier)`)에도 동일하게 적용됨을 명시 — +Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processor +부재)가 그대로 적용되고, 별도로 다시 논증할 필요 없음. 위 "하드 경계"와 +같은 이유로, `Emit`이 Modifier의 정적 flatten과 충돌할 걱정 자체가 +성립하지 않음(둘이 만날 지점이 없음). ## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md new file mode 100644 index 0000000..e2975d7 --- /dev/null +++ b/.claude/research/documentation-content-map.md @@ -0,0 +1,174 @@ +# 문서 콘텐츠 분류 맵 (초심자/api/심화/skip) + +**상태**: research — `documentation-plan.md` 0번 항목(3축 구조: 초심자/api/심화 ++ 백엔드별 트랙 분리)이 확정된 뒤, 실제로 각 축에 뭘 채울지 `.claude/base/*.md` +전체 + 관련 `research/*.md`(tween-plan, ui-shorthand-plan)를 2026-08-06 세션에 +6개 에이전트로 병렬 서베이해 분류함. **아직 문서를 쓰라는 뜻 아님** — 착수 +시점은 여전히 구현 우선(`CLAUDE.md` "지금 할 일" 1번). 나중에 실제 문서화를 +시작할 때 이 맵을 목차/우선순위표로 쓰면 됨. + +**api↔심화 연결 원칙(사용자 확정)**: api 문서는 항목마다 설명을 간략하게 +유지하고, 근거·내부 동작까지 파고드는 내용은 심화 섹션으로 링크("더 알아보기 +→ 심화")하는 방식으로 연결. 아래 [api] 항목 중 "→심화"가 붙은 것들이 이 +패턴 대상. + +**분류 기준**: [초심자] core loop에 필수(백엔드 구체적, quad-roblox 기준) / +[api] 레퍼런스, 빠른 룩업용 짧은 설명 / [심화] 왜 이렇게 설계했는지, 최적화· +대규모 코드베이스 관리 관심자용 / [skip] 내부 설계 과정 기록, 최종 사용자 +문서엔 안 들어감(세션 날짜, 정정 이력, 조사 원자료 등). + +--- + +## 1. 초심자(getting-started) core loop — 취합된 목차 초안 + +전체 서베이에서 나온 [초심자] 항목을 실제 학습 순서로 재배열한 것. 이대로 +목차를 잡으면 좋아 보임(그대로 확정은 아니고 초안): + +1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`) +2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 `new` + 자주 쓰는 ~25개 클래스 정적 필드(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`) +3. **속성 채우기** — `[Attribute "Name"]`, `[Tag ""] = true` 특수 바인드 키 (`architecture.md`) +4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`bind-system-plan.md`, `store-semantics.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜) +5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`) +6. **자식 전달** — Slot 기본 개념(children 배열, add/remove/clear), 마운트된 slot 재마운트 시 throw (`slot-plan.md`) +7. **컴포넌트 작성** — 컴포넌트 = 순수 함수, 리프 프로퍼티엔 State만 바인딩, 전역 store 직접 참조 금지(이식성) (`component-composition-plan.md`, `purity-and-effects-plan.md`) +8. **컴포넌트 경계 넘기기** — `props.Modifier`/`props.Ref` named parameter 패턴 (`component-composition-plan.md`) +9. **이벤트** — self(Instance) 안 받음, 문자열 키(`Frame { MouseButton1Click = fn }`) (`bind-system-plan.md`) +10. **생명주기** — GC 위임(수동 정리 불필요), Destroy 이후 대상 재사용 금지 (`lifecycle-pattern.md`) +11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `CreatedRef(fn, {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 한정)** — `Corner`/`PaddingAllOffset`/`Scale` 인라인 키 (`research/ui-shorthand-plan.md`) + +--- + +## 2. 파일별 상세 분류 + +### architecture.md +- 초심자: DOMless 즉시 Instance 생성 모델 / 특수 바인드 키 / Ref 기본 개념 / modifier 기본 사용법(스타일링) / Store·State·Source 온톨로지 핵심 동작 / quad-base·quad-roblox 패키지 구조 존재 사실 +- api: Class 함수형+`:` 체이닝 예외 규칙 / store 바인드=전체 교체 의미론 / Tag/retract(TagService 기반) / 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 기본 개념+`CreatedRef` / 이벤트 self 미채택 기본 규칙+문자열 키 / 인스턴스 생성(제네릭+정적 필드) / 라이브러리 초기화 3줄(`RobloxFactory(QuadBase)`) +- api: `state:Observer(fn)` 사용법(→심화: weak-table 내부 인덱싱) / `:Subscribe()`/`:Unsubscribe()` 시그니처(→심화: 강참조 레지스트리 구조) / Ref 일반화 표면 API(→심화: "왜 값이 아니라 콜백인가") / 이벤트 store-bind 존재+권장 안 함 가이드(→심화: 엔지니어링 비용 근거) / 핸들러 4종 계약(`isHandlable`/`priority`/`process`/`retract`) / `Attribute` 특수 키 후보(미확정 명시 필요) +- 심화: push-invalidate/pull-recompute 전파 모델+"관측해야 실체화된다" 원칙+`previous` 캐비엇 / **왜 State를 Modifier처럼 플래튼하지 않는가**(이미 문서화 완료, 아래 3번 참고) / Store가 Store를 못 담는 이유 / 이벤트 self 미채택 4가지 근거 / store-bind 재귀 래핑 내부 메커니즘, retract가 Destroy 시 호출 안 되는 이유 / 같은 팩토리 재호출 no-op·다른 팩토리 충돌 에러 내부 안전장치 +- skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약 + +### component-composition-plan.md / module-lifecycle-plan.md +- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `props.Modifier`/`props.Ref` named parameter 경계 전달 / `InitRoblox(Module)` 팩토리 초기화 +- api: State(파생, 읽기전용) vs Source(원본, 쓰기가능) 경계 요약(→심화) / Slot 반환 컴포넌트는 Modifier/Ref 파라미터 미선언 / `Modifier.Merge(mod1, mod2, ...)` 유틸 / 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가지 조합 표(→심화: 반응성 유지/끊김 이유) / `Modifier.Rounded(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) +- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) +- 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정 +- skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 + +### store-semantics.md / tween-plan.md / ui-shorthand-plan.md +- 초심자: Store 생성+`myStore.key = value` 문법 / `store.key`로 State 얻기 개념 / Tween 기본 바인드 키+취소 기본 동작 / UI 숏핸드 인라인 키 기본 예시(`Frame { PaddingAllOffset = 50 }`) +- api: `:With`+`:Compute` 시그니처(→심화) / `source:Emit()` 존재+"Get() 결과 캐시 금지" 캐비엇(버그 유발 포인트라 api에도 명시 가치 있음, →심화; 2026-08-06 후속 세션에서 `Store:Emit(key)`→`source:Emit()`로 호출부 변경, `store-semantics.md` 참고) / Tween 핸들러가 Instance 직접 받음(Ref 불필요) / retract는 Destroy 시 호출 안 됨(→심화) / UI 숏핸드 키 목록 레퍼런스 표 / Modifier와 순수 인라인 키 동등성 +- 심화: Source·Store·State·Observer 온톨로지(독립 프리미티브 vs 파생 데이터 원칙, 생성자 모양 근거) / `Emit`이 Source 전용인 이유(디버깅 그래프 무결성) / `Store`의 T가 Modifier 불가인 이유 / Tween을 반응 그래프 밖 특수 bind key로 둔 이유(Fusion 반면교사) / RoundSize 포팅 불필요 vs Corner/PaddingAll/Scale 필요 이유 / "작고 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` + +(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 경험자 대상 비교 + +**publish 안 하는 것과의 경계**: 세션별 정정 이력, 조사 원자료(Fusion +반응 그래프 BFS 분석, quad2-try 죽은 코드 조사 등)는 quadnomicon에도 +안 들어감 — 그건 새 티어가 필요한 게 아니라 애초에 `.claude/` 내부 +설계사로만 남고 절대 publish 안 하는 것(RFC 논의 저장소 같은 성격, +위 각 파일 섹션의 skip 참고). quadnomicon은 잘 다듬은 소수의 큐레이션된 +에세이 공간이지, 내부 연구 기록을 그대로 옮기는 곳이 아님. + +**배경지식 자체가 깊은 주제(예: GC) 처리 방침**: 새 티어를 만들지 않음. +"quad가 GC를 어떻게 활용하는가"(quad 고유)는 심화에 그대로 두되, "GC란 +무엇인가" 자체를 가르치는 자체 튜토리얼은 안 쓰고 외부 좋은 자료로 +링크 처리 — 안 그러면 문서 프로젝트가 일반 프로그래밍 교육 쪽으로 +스코프 크리프될 위험이 있음(사용자 판단). + +## 5. 문서화 아직 보류(미확정 설계라 쓰면 안 됨) + +- Slot 형제 순서 보장 (`slot-plan.md`) +- Tween 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름 (`research/tween-plan.md`) +- UI 숏핸드 `RoundSize` 드롭 여부 (`research/ui-shorthand-plan.md`) +- `Attribute` 제네릭 vs 타입별 정적 생성자 (`bind-system-plan.md`) +- provider/processor 네이밍 (`module-lifecycle-plan.md`) + +이 항목들은 `.claude/question.md`에도 이미 열린 질문으로 잡혀있음 — 여기선 +"확정 전엔 문서화 대상 아님"이라는 표시만 겸함. + +## 다음 단계 + +이 맵 자체를 지금 실행할 필요는 없음(구현 착수가 여전히 최우선, +`documentation-plan.md` "다음 단계" 참고). 나중에 실제로 문서 사이트 +작업을 시작할 때: (1) 위 1번 목차 초안으로 초심자 트랙 스캐폴딩, (2) 파일별 +[api] 항목으로 레퍼런스 페이지 스캐폴딩, (3) 4번 리스트를 심화 섹션 +에세이 백로그로 사용. diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md index e940e7b..f65f406 100644 --- a/.claude/research/documentation-plan.md +++ b/.claude/research/documentation-plan.md @@ -5,7 +5,80 @@ 판단해 뼈대만 기록해둠. 세부 설계는 없음, 착수 시점도 미정 — 사용자가 "간단히 plan 작성은 하는 게 맞을 듯, 아주 뼈대만 있는 것이라 할지라도 적어줄 필요는 있겠다"고 판단해서 만든 문서. 같은 날 후속 세션에서 -이벤트 핸들링 self 관습 논의가 확정되며 3번 항목이 추가됨. +이벤트 핸들링 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 요소 네이밍 컨벤션 문서 @@ -85,6 +158,19 @@ UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같 - 어느 문서에 넣을지 — 퀵스타트? 위 1번 네이밍 컨벤션 문서와 마찬가지로 아직 미정. +## 4. 심화 문서 소재 후보 (누적, 2026-08-06 추가) + +설계 논의 중 "이건 왜 이렇게 안 했는가/이렇게 했는가" 식으로 나온 세세한 +근거들 중, 나중에 0번 항목의 "심화" 축에 넣을 만한 것들을 누적 기록. +지금 당장 문서를 쓰라는 뜻 아님 — 원 결정문은 각 `base/` 문서에 이미 +있으므로 여기선 소재 제목과 위치만 남김: + +- **왜 State 체인을 Modifier처럼 플래튼하지 않는가** — 결정문은 + `base/bind-system-plan.md` "왜 State 체인을 Modifier처럼 플래튼하지 + 않는가" 절. State의 정의가 "캐싱하는 존재"라 다이아몬드 파생에서 + 캐시 공유를 잃으면 `previous` 메커니즘이 막으려던 중복 계산 문제가 + 재발한다는 게 핵심 논거. + ## 다음 단계 셋 다 지금 당장 설계할 필요는 없음(`CLAUDE.md` "지금 할 일" 1번 — diff --git a/.claude/research/framework-comparison-findings.md b/.claude/research/framework-comparison-findings.md new file mode 100644 index 0000000..50a9441 --- /dev/null +++ b/.claude/research/framework-comparison-findings.md @@ -0,0 +1,110 @@ +# quad vs Fusion/Vide/react-lua — 정직한 비교 (2026-08-06) + +**상태**: research — 3개 에이전트가 각각 Fusion(`.claude/initreq/fusion` 실 +소스), Vide(`.claude/initreq/vide` 실 소스), react-lua(로컬 클론 없어 +웹 리서치)를 직접 읽고 quad의 확정 설계와 대조. 목적은 마케팅이 아니라 +정직한 자가점검 — "quad가 진짜 나은 부분"(나중에 초심자/quadnomicon 문서의 +"왜 quad인가" 소재)과 "quad가 진짜 불리한 부분, 그중 고칠 수 있는 것"을 +사용자가 직접 검토하기 위함. **quad는 구현 0줄** 상태라 모든 비교가 +"검증된 프로덕션 코드 vs 종이 설계"라는 근본적 비대칭을 안고 있음 — 아래 +모든 강점/약점은 이 전제하에 읽을 것. + +## 1. quad가 실제로 나은 점 (소스 근거 있음, 향후 "왜 quad인가" 문서 소재) + +- **Slot 단일 마운트 가드 — Fusion·Vide 둘 다 없음, 실재하는 버그 클래스를 막음.** + Fusion `Children.luau`엔 `-- TODO: check for ancestry conflicts here` + 주석이 그대로 남아있고 이미 마운트된 인스턴스를 조건 없이 재부모화함(조용한 + 이중 마운트). Vide `mount.luau`도 중복 마운트 체크가 전혀 없음. quad의 + "이미 마운트된 Slot 재마운트 시 즉시 throw"는 둘 다에 없는 실질적 안전장치. +- **열린 우선순위 축 — Fusion의 하드코딩된 4단계보다 확장성 좋음.** Fusion + `applyInstanceProps.luau`는 `{self, descendants, ancestor, observer}` + 정확히 4개 버킷만 갖고 5번째를 쓰면 에러남. quad의 열린 숫자 우선순위 + 레지스트리는 커스텀 bind key를 라이브러리 수정 없이 임의 우선순위에 + 끼워넣을 수 있음. +- **명시적 의존성이 여러 버그 클래스를 원천 차단.** Vide는 전역 `scopes` + 스택 기반 암묵 추적이라 리액티브 스코프 안 yield가 그래프를 깨는 걸 막기 + 위해 별도 `ycall` 장치까지 둠(`graph.luau`). quad의 명시적 `:With` + 의존성 전달은 이 버그 클래스 자체가 발생하지 않음. +- **다이아몬드 의존성 재계산 dedup — Vide가 스스로 미해결로 남긴 문제를 + 더 구조적으로 해결.** Vide `todo.md`가 diamond 그래프 중복 재평가 방지를 + 미해결로 인정했고, 실제로 `test/tests.luau`의 "recursive queue flush + diamond" 테스트가 이상적 2회 대신 3회 실행됨을 재현함. quad의 `invalid` + 플래그 dedup은 이걸 원시 레벨에서 막도록 설계됨. +- **fine-grained라 vdom 특유의 버그 클래스가 통째로 없음(vs react-lua).** + react-lua는 리스트 diffing을 위해 key 관리가 필요하고(불안정하면 자식 + 상태 유실), hooks 호출 순서 규칙이 있으며(위반 시 "Rendered fewer/more + hooks" 에러), 고빈도 갱신엔 리렌더를 우회하는 별도 API(`Bindings`)를 + 공식적으로 추가해야 했음(react-lua 스스로 "vdom 재조정만으론 부족하다"고 + 인정한 셈). quad는 모든 값이 동일한 push-invalidate/pull-recompute + 모델이라 이 세 문제 자체가 없음. +- **Tween을 그래프 밖에 둬서 구조적 복잡도를 회피.** Fusion `Animation/ + Tween.luau`는 `Stopwatch`+`ExternalTime` 그래프 노드와 + `checkLifetime.bOutlivesA` 교차 lifetime 검증까지 필요한 3중 장치. + quad엔 이 장치 자체가 없음(단, 반대급부는 아래 3번 참고). + +## 2. quad가 불리한 점 중 — 고칠 만한 것(fixable, 검토 가치 있음) + +- **use-after-destroy 검증 안전망 부재.** Fusion `Memory/checkLifetime.luau`는 + "짧게 사는 스코프가 오래 사는 대상에 바인딩됐다" 같은 실수를 사람이 읽을 + 수 있는 에러 메시지로 즉시 잡아줌. quad `base/lifecycle-pattern.md`엔 + 이런 사전 검증 개념이 없음. GC-native 프로덕션 동작 자체를 바꿀 필요는 + 없고, **개발/Studio 모드 한정 옵트인 검증 레이어**(quad-debug류와 결합 + 가능)로 추가하는 정도는 GC-native 철학과 안 부딪히고 고려해볼 만함. +- **`:With(...)` 정적 의존성 목록 — Fusion의 동적 재평가보다 약함.** Fusion + `evaluate.luau`는 매 평가마다 실제 `use()`된 의존성만 다시 구독해 특정 + 라운드엔 조건부로 일부 의존성을 아예 구독 안 할 수 있음. quad는 `:With` + 에 나열한 목록이 Compute 시점에 고정돼, lazy handle로 재계산 트리거는 + 피해도 무효화 신호 자체는 계속 도착해 불필요한 재-Get이 누적될 수 있음. + **동적 With 등록/해제 API** 정도로 완화 가능해 보임 — 우연한 갭에 가까움. +- ~~Store dot-access가 매 접근마다 새 State를 할당~~ — **[해소됨, + 2026-08-06 세 번째 세션]** 이 항목이 직접 트리거가 되어 Source/State + 관계 자체를 재구성(`store-semantics.md` "Source가 State를 만족함" 절) — + Store가 이제 생성 시 만들어둔 Source를 그대로 반환해 wrapper 할당 자체가 + 없어짐, 구현 단계 최적화가 아니라 설계로 완전히 없앰(캐싱/풀링보다도 쌈). + +## 3. quad가 불리한 점 중 — 못 고치는 것(의도된 트레이드오프, "고친다" 개념 자체가 안 맞음) + +- **암묵적 추적의 인체공학적 우위(vs Vide)** — `derive()` 안에서 그냥 + 호출하면 의존성이 잡히는 Vide 대비, quad는 전부 `:With`에 나열해야 해 + 보일러플레이트가 늘어남. quad가 "Lua에서 암묵 추적은 부작용 관찰이 + 필요해 지저분하다"는 이유로 의도적으로 거부한 결과라, 명시성을 유지하는 + 한 고칠 개념 자체가 아님(경감책은 있을 수 있음 — 아래 4번 참고). +- **Tween이 그래프 밖이라 다른 Compute의 입력으로 자유롭게 합성 불가(vs + Fusion/Vide)** — Fusion Tween/Spring, Vide `spring()`은 그래프 노드라 + 다른 파생값의 입력으로 얽어 쓸 수 있음. quad는 Fusion을 반면교사 삼아 + 의도적으로 이 경로를 포기한 것이라 원 설계 취지와 충돌. 필요해지면 + 옵트인 브릿지 추가가 현실적 타협(지금 급한 건 아님). +- **GC-native 라이프사이클 자체가 안고 있는 리스크** — Vide는 GC와 + `Instance.Destroying` 순서가 비결정적이라는 알려진 함정 때문에 의도적으로 + eager·수동 cleanup을 택함. quad의 "수동 dispose 불필요"는 GC 의존을 + 없애려면 결국 Vide식 수동 owner 트리로 돌아가야 해서 철학과 충돌 — + 다만 `base/lifecycle-pattern.md`의 rbvm 실물 검증 근거로 리스크는 이미 + 어느 정도 완화돼 있음(기존 base 문서 참고). +- **DOMless+컴포넌트 1회 실행 때문에 "지금 트리가 어떻게 생겼는가"를 + 한눈에 재구성하기 어려움(vs react-lua)** — react-lua는 렌더마다 전체 + 서브트리를 선언적으로 다시 기술해 현재 상태가 코드 한 곳에 드러남. quad는 + 변화가 개별 leaf bind에 흩어져 처리돼 복잡한 조건부 트리 추론이 어려움. + 근본 선택에서 필연적으로 따라오는 트레이드오프라 설계 변경으론 해소 안 + 되고, quad-debug 같은 관측 도구로만 보완 가능(이미 백로그에 있음 — + `research/debug-tooling-plan.md`). + +## 4. 성숙도 격차 — 설계 결함 아니지만 지금 시점 비교에선 정직하게 명시해야 함 + +Fusion(~5000줄+테스트+수년 실사용), Vide(2800줄+테스트+0.1.0→0.4.1 하드닝 +이력), react-lua(Roblox 사내 실사용+전용 벤치마크 레포)는 전부 실전에서 +발견되고 고쳐진 문제들의 산물. quad는 구현이 0줄이라 이 비교의 강점 항목도 +전부 M0 스파이크 이후 실제 Luau로 검증돼야 신뢰할 수 있고, 구현이 진행되면 +유사한 이유로 비슷한 안전장치를 뒤늦게 추가하게 될 가능성이 있음(1번의 +use-after-destroy 검증처럼). "hooks 없는 quad의 `:With`/`:Compute`가 +React 커스텀 훅만큼의 합성성을 실사용 규모에서 주는가"도 지금은 데이터 +없음 — 고칠 문제인지조차 판단 이를 정도로 이름. + +## 다음 단계 + +이 문서 자체는 지금 당장 뭘 바꾸라는 결정문이 아님 — 사용자가 직접 검토 +후 판단할 항목: +- 2번의 남은 두 가지(use-after-destroy 검증, 동적 With)를 실제로 설계에 + 반영할지, 반영한다면 언제(M0 스파이크 때 같이 검증할지, 나중 최적화 + 패스로 미룰지). 세 번째(Store dot-access 할당)는 위에서 이미 해소됨. +- 1번 강점 목록은 `research/documentation-content-map.md`의 "왜 quad를 + 쓰는가" 초심자/quadnomicon 콘텐츠 소재로 재사용 가능. diff --git a/CLAUDE.md b/CLAUDE.md index 11ef18b..695e0fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,12 +38,19 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `.claude/README.md`가 색인. 요약: - `.claude/base/` — 확정된 아키텍처/컨텍스트, plan/done 개념 없음. 먼저 `.claude/base/architecture.md`를 읽을 것. -- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 지금은 - `tween-plan.md`(세부 옵션만 남음), `existing-instance-bind-plan.md`(급하지 - 않음) 두 개뿐 — `component-composition-plan.md`는 2026-08-04 세션에 수렴 - 완료돼 `base/`로 승격됨. -- `.claude/qa-request/`, `.claude/archive/`, `.claude/feedback/` — 구현 - 시작되면 쓰기 시작함, 지금은 비어있음. +- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. + `tween-plan.md`/`existing-instance-bind-plan.md`/`debug-tooling-plan.md`/ + `ui-shorthand-plan.md`/`documentation-plan.md`/`documentation-content-map.md`/ + `framework-comparison-findings.md` — 전부 후순위(급한 건 `tween-plan.md` + 세부 옵션 정도). 최신 목록·우선순위는 `.claude/README.md`가 소스, 여기서 + 개수 반복 안 함(과거에 "두 개뿐"이라 적어놨다가 새 문서 추가될 때마다 + 안 갱신되는 패턴이 반복돼서 아예 안 세기로 함). +- `.claude/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, + 지금은 비어있음. `.claude/archive/`는 원래 같은 취급이었으나 + 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 + 이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이 + 아님) — `archive/store-source-proxy-reversed.md`가 첫 사례, 나중 + `quadnomicon` 콘텐츠 소재로 재사용 예정. - `.claude/initreq/` — 클론해둔 참고 레포(quad v1, Fusion, Vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`) + 원본 요청. **읽기 전용, `.gitignore`로 커밋 제외됨** — 내용을 다른 곳으로 옮기지 말고 항상 원본 @@ -121,9 +128,13 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 채널 실현 가능성(Studio 플러그인↔Play 중 게임 간 BindableEvent/Function 통신)까지 실측 검증 완료, 세부 API 이름만 남음. 착수 시점은 여전히 "quad 개발 상당 부분 끝난 뒤"로 사용자가 못박음. 같은 날 파생된 문서화 - 전략 뼈대(`research/documentation-plan.md` — UI 네이밍 컨벤션, Store - 부작용 게임 시스템 활용 패턴, 권장 이벤트 핸들링 패턴 3종)도 후순위 - 백로그로 같이 남김. + 전략(`research/documentation-plan.md` — UI 네이밍 컨벤션, Store 부작용 + 게임 시스템 활용 패턴, 권장 이벤트 핸들링 패턴 3종)도 후순위 백로그로 + 같이 남김. **[2026-08-06 세 번째 세션에서 크게 확장됨]** 문서 사이트 + 전체 구조(초심자/api/심화/`quadnomicon` 4축)와 실제 콘텐츠 분류맵 + (`research/documentation-content-map.md`), quad vs Fusion/Vide/react-lua + 정직 비교(`research/framework-comparison-findings.md`)까지 늘어남 — + 착수 우선순위 자체는 안 바뀜(여전히 후순위), 아래 최신 세션 요약 참고. 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). @@ -411,3 +422,83 @@ Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.** 쌈). 내부 레지스트리는 자동 케이스의 weak table과 별개로 강참조 (weak면 "살려둔다"는 목적이 무의미해짐). 둘 다 idempotent, `:Unsubscribe()`는 자동 케이스 조기 해제에도 재사용. + +## 2026-08-06 세 번째 세션 — 문서 사이트 구조, 프레임워크 정직 비교, Source가 State를 만족하는 서브타입 재구성 + +같은 날 이어진 세 번째 세션. 셋으로 갈리는 주제라 순서대로 요약 — **다음 +세션이 새로 알아야 할 건 4번(Source/State 재구성)뿐**, 1~3번은 배경/참고용. + +**1. 문서 사이트 구조 확정 — 초심자/api/심화 3축 + `quadnomicon` 4번째 축.** +`research/documentation-plan.md` 0번 항목에 전부 반영. 초심자는 "core loop +완주에 필요한 최소 집합만, 백엔드 구체적(quad-roblox), quad-base/roblox +분리 노출 안 함, 다른 백엔드 생기면 그때 별도 트랙 추가"로 스코프 확정. +api는 간략 설명 + 심화로 "더 알아보기" 링크 패턴. `quadnomicon`(Rustonomicon +패러디, 사용자 확정 이름)은 quad 사용자가 아니라 "비슷한 프레임워크를 +설계/포크하려는 엔지니어"용 4번째 축 — Fusion/Vide 내부 비교 같은 콘텐츠가 +여기 해당, 세션 정정 이력 같은 순수 내부 리서치 원자료는 이 축에도 안 +들어가고 그냥 `.claude/` 내부에만 영구히 남음(RFC 저장소 성격). GC처럼 +quad 밖 배경지식이 깊은 주제는 새 티어 없이 "quad 활용법만 심화에, 일반 +개념은 외부 링크"로 처리. 실제 콘텐츠 분류(초심자 core loop 목차 초안, +파일별 분류, 심화 에세이 후보 15개)는 `research/documentation-content-map.md`. + +**2. quad vs Fusion/Vide/react-lua 정직 비교 — 3개 에이전트가 실제 +소스/웹 리서치로 검증.** `research/framework-comparison-findings.md`. +요지: quad의 Slot 단일 마운트 가드·열린 우선순위 축·명시적 의존성·다이아몬드 +dedup은 실 소스 근거로 확인된 진짜 강점(Fusion `Children.luau`의 TODO +주석, Vide `mount.luau`의 중복 체크 부재, Vide 자신이 `todo.md`에 미해결로 +남긴 diamond 문제 등). 반대로 use-after-destroy 검증 안전망 부재·`:With` +정적 의존성·Store dot-access 할당 비용 3가지는 고칠 만한 약점으로 식별(3번은 +이후 4번 논의로 이미 해소됨). GC-native 리스크·암묵 추적 대비 보일러플레이트· +Tween 비합성성·"지금 트리 상태" 파악 어려움은 의도된 트레이드오프로 "고친다" +개념 자체가 안 맞음. 성숙도 격차(quad 구현 0줄)는 정직하게 명시. + +**3. 위 1·2번에서 파생된 실행 항목**: 아직 결정 아님, `research/ +documentation-plan.md`/`framework-comparison-findings.md`의 "다음 단계"에 +남겨둔 사용자 판단 대기 항목들(문서화 착수 시점, 프레임워크 비교에서 나온 +개선안 반영 여부/시점) 그대로 참고. + +**4. Source가 State를 구조적으로 만족 — Store/State/Source 핵심 메커니즘 +재구성, base 문서 전부 반영 완료.** `store.key`의 타입 문제(레코드 타입 +`{key: State}`가 읽기/쓰기 비대칭이라 Luau 타이핑이 안 맞음)를 +풀다가 나온 더 근본적인 재구성: +- **`Source`가 구조적으로 `State`를 만족**(단방향 호환, Svelte + `Writable extends Readable`와 같은 모양) — `.value`/`:Get()`/ + `:With`/`:Compute` 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. `:With`/ + `:Compute`는 Source에서도 항상 `State` 반환(구현은 metatable `__index` + 델리게이션, `Modifier`의 제네릭 `__index` 트릭과 같은 패턴이라 로직 + 중복 없음). 이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 + OOP 상속과 다른 층위(프리미티브 타입 간 구조적 서브타이핑일 뿐, 사용자가 + 짜는 클래스 계층 구조가 아님)라 그 금지와 안 부딪힘. +- **`RefSource`(store 슬롯 전용 타입 중간안)와 그 전신인 `StoreSource` + 프록시(2026-08-04 세션에서 confirmed였던 것)는 전부 폐기.** Store는 + 이제 "이름 붙은 Source 모음, 그 이상 아님" — `store.key`는 Store 생성 + 시 이미 만들어둔 진짜 Source 객체를 그대로 반환(별도 wrapper 생성/캐싱 + 단계 자체가 사라짐, 이전에 검토한 "State를 weak table로 캐싱"보다도 + 쌈). v1이 타입 없던 시절 습관으로 모든 값을 Store에 몰아넣은 건 "당시엔 + 편해서"였지 지금 그대로 가져올 이유가 아니라는 게 사용자의 회고적 + 재평가 — 그 재검토가 이번 단순화로 이어짐. +- **`store.key = value`(`__newindex`) 폐기, `store.key:Set(value)`로 + 전환** — 이유 둘: (a) 레코드 타입 `{key: Source}`가 읽기/쓰기 + 둘 다 같은 타입이어야 Luau 타이핑이 깨끗한데 대입 문법을 유지하면 + 비대칭이 남음, (b) `=`는 관례상 "즉시 커밋되는 부작용 없는 쓰기"를 + 암시하는데 quad는 실제로 lazy(무효화 신호만 쏘고 재계산은 관측 시점에)라 + 대입 문법이 실제 동작과 정서적으로 안 맞음(사용자 논거). `Store:Emit(key)`도 + 같은 이유로 `source:Emit()`(key 인자 불필요)로 이동 — 같은 일 하는 + 두 번째 경로를 안 남긴다는 원칙과 일치. +- **검증 필요, M0 스파이크에 항목 추가됨(`ROADMAP.md`)**: Source의 + `:Compute` 시그니처가 자기 자신과 `State`를 동시 참조하는 제네릭 + 메소드라 Luau 솔버가 재귀 타입 조합에서 안 막히는지 확인 필요. 자기 + 참조 self 타이핑 자체는 흔하고 안전하나, `State`가 거꾸로 `Source`를 + 참조하는 **상호 재귀**는 Luau의 알려진 취약 패턴이라 피해야 함 — + `State`를 `Source` 참조 없이 독립적으로 먼저 정의하고 `Source`만 + 단방향으로 `State`를 참조하게 두면 이 위험을 피할 수 있어 보이나 + 확정 아님. 타입은 `&`(교차) 조합 대신 손으로 펼쳐 쓰는 쪽으로(사용자 + 선호, 솔버 안정성 우선) — 이건 런타임 구현 델리게이션과 다른 축이라 + 서로 안 부딪힘(타입은 펼치고 구현은 공유 가능). +- **반영된 파일**: `base/store-semantics.md`(신규 "Source가 State를 + 만족함" 절이 최종 소스), `base/bind-system-plan.md`(온톨로지·타입 추론 + 절 정정), `base/component-composition-plan.md`(`StoreSource`/타입 + 유니온 절 재작성), `ROADMAP.md`(M0 항목 추가), `research/ + documentation-content-map.md`/`.claude/README.md`(참조 갱신). 이름 + 자체(`Source`/`State`)는 여느 때처럼 "지금 할 일" 2번 용어 정리 + 라운드까지 가칭. diff --git a/ROADMAP.md b/ROADMAP.md index f4c9f03..a794898 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,6 +17,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] Store/State push-invalidate → pull-recompute propagation을 실제로 짜보기(다이아몬드 의존성 케이스 포함 — 이미 invalid면 전파 중단되는지) +- [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute(self: + Source, ...) -> State`류, self 타이핑 + State 참조 혼합)이 + Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 후속 세션, + `base/store-semantics.md` "Source가 State를 만족함" 절 — `State`가 + `Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 + 피할 수 있어 보이나 실제 검증 전엔 확정 아님) - [ ] `process`/`retract` 재귀 재-process 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) - [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, From c5ae5de35f21f47ae73c66ea09b9a3eb2de20cb9 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 21:38:48 +0900 Subject: [PATCH 20/62] =?UTF-8?q?M0=20=EC=B0=A9=EC=88=98=20=EC=A7=81?= =?UTF-8?q?=EC=A0=84=20=ED=81=AC=EB=A6=AC=ED=8B=B0=EC=BB=AC=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=E2=80=94=20research/pre-implementation-audit.md=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4개 서브에이전트로 base/ 전체 + 근접 research/를 모호성/지연결정리스크/ 단순화후보 세 렌즈로 재감사. 가장 구조적인 발견은 Tween.luau가 문서 전체에서 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 서술되는 문제 (일반 반응형 프로퍼티 바인딩이 실제로 Tween 파일을 거쳐가는지 불명확). 그 외 props.Modifier/Ref forwarding의 nil-hole 함정, canExecute 실제 구현 미확정, LifetimeHandle 로드맵 순서 역전 등 우선순위1급 11개 + 우선순위2급 11개 + 단순화후보 2개. 부수적으로 architecture.md 소스트리의 stale 주석(Store.luau/Ref.luau) 정정, question.md/README.md에 이 감사 반영, CLAUDE.md에 세션 요약 추가. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 1 + .claude/base/architecture.md | 4 +- .claude/question.md | 34 +- .claude/research/pre-implementation-audit.md | 567 +++++++++++++++++++ CLAUDE.md | 69 ++- 5 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 .claude/research/pre-implementation-audit.md diff --git a/.claude/README.md b/.claude/README.md index 5e23b71..21a0c1d 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -47,6 +47,7 @@ | `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 | | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 진짜 불리한 점 중 고칠 만한 것 3개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 | | `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | +| `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1(M0~M4 착수 전 확인 권장) + 11개 우선순위2 + 2개 단순화후보 | 상 — M0 착수 전 최소 우선순위1 항목 확인 권장 | ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index c21205f..12700f8 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -124,7 +124,7 @@ quad/ │ └── src/ │ ├── Source.luau # 값의 근원, 단일 지점 │ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기 -│ ├── Store.luau # source 집합체, dot-access, __newindex +│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환 │ ├── Dispatch/ │ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔 │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) @@ -132,7 +132,7 @@ quad/ │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) │ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" -│ ├── Ref.luau # CreatedRef 메커니즘(숫자 슬롯 참가자) +│ ├── Ref.luau # 범용 값 박스(.Value/:Wait()/콜백) + 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 │ └── init.luau └── quad-roblox/ ├── wally.toml diff --git a/.claude/question.md b/.claude/question.md index 5edd49c..6dd8844 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -53,7 +53,39 @@ - `Store`/`Source`/`Modifier`/`Ref`/`process`/`retract`/`isHandlable`은 업계 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. -### 2. 낮은 우선순위 +### 2. 구현 착수 직전 감사 결과 (2026-08-06 신설, M0 착수 전 확인 권장) + +`research/pre-implementation-audit.md` — `base/` 전체를 M0 착수 직전 +시점에서 모호성/지연결정리스크/단순화후보 세 렌즈로 재감사한 결과. 총 +11개 우선순위1(구현 중 바로 부딪힐 가능성 높음) + 11개 우선순위2(지금 +정해두면 싼 지연리스크) + 2개 단순화후보. 전체는 그 문서 참고, 특히 +사용자 판단이 필요한 것 위주로 요약: + +- **Tween.luau가 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 서술됨** — + 일반 반응형 프로퍼티 바인딩(`BackgroundColor3 = store.color`, 애니메이션 + 없음)이 결국 이름은 "Tween"인 파일을 거쳐가는 건지, 아니면 별도 범용 + `Handlers/StoreBind.luau`가 있어야 하는 건지 확정 필요 — 우선순위1-1. +- **`State` 타입 차단(엔지니어링 비용 감수)과 Ref/Slot이 Modifier + 필드에 들어가는 건 UB 방치 — 같은 문서 안에서 정반대 원칙이 근거 설명 + 없이 나란히 적용됨.** 왜 이 경우만 예외로 방어하는지 명문화 필요, 또는 + Luau에서 실제 타입 차단이 가능한지부터 확인(안 되면 그냥 UB로 격하) — + 문서모순 절 + 우선순위2-2. +- **`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 + nil-hole 함정에 그대로 노출됨** — caller가 Modifier/Ref를 안 넘기면 + `{nil, ref, child}`에서 뒤 항목까지 통째로 무시될 수 있는 버그 클래스. + M0 스파이크에 이 케이스(안 넘기는 경우)를 반드시 포함시킬 것 — 우선순위1-5. +- **`canExecute`/`Connected`의 실제 구현 방식(Parent==nil vs Connection. + Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind + retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6. +- **`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 + 필요로 함(로드맵 순서 역전)** — quad-base 인터페이스 정의를 M2/M3로 + 옮기는 게 자연스러워 보임, `ROADMAP.md` 수정 필요 — 우선순위1-9. +- 그 외(Slot CRUD 의미론 미정의, retract 시 "이전 핸들러" 추적 책임 소재, + 우선순위 스캔 동률/매치실패 처리, `:Compute`의 `previous` 인자가 + 오버엔지니어링일 수 있음, UI shorthand의 기존 UICorner 매칭 기준 등)는 + `pre-implementation-audit.md` 본문 참고. + +### 3. 낮은 우선순위 - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 착수를 막지 않음. diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md new file mode 100644 index 0000000..e7d497c --- /dev/null +++ b/.claude/research/pre-implementation-audit.md @@ -0,0 +1,567 @@ +# 구현 착수 직전 감사 — 모호성 / 지연결정 리스크 / 단순화 후보 + +**상태**: 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 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험 + +**위치**: `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 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 + +**위치**: `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 함정에 그대로 노출됨 + +**위치**: `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`의 실제 구현 방식이 미확정인 채로 코어 전역에 이미 재사용 확정됨 + +**위치**: `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 의미론이 정의돼 있지 않음 + +**위치**: `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"가 두 가지 다른 추적 대상을 혼용해서 서술됨 + +**위치**: `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이 이미 그걸 필요로 함(로드맵 순서 역전) + +**위치**: `ROADMAP.md` M8 "Ref" — `"LifetimeHandle 인터페이스 + quad-roblox +실제 구현(Instance 생존 확인)"`. + +**문제**: `base/lifecycle-pattern.md`("생명 바인드 유틸"을 State-invalidate +리스너 클로저 등록에도 재사용)와 `base/slot-plan.md`(Slot의 `retract`가 +같은 canExecute 패턴을 그대로 씀)는 둘 다 이 유틸을 State/Store 구독 +(M3/M4 영역)과 Slot(M6)에서 이미 쓴다고 명시하는데, `LifetimeHandle` +인터페이스 자체는 M8에서야 정의된다. 즉 M4/M6이 개념적으로 필요로 하는 +base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전. + +**제안**: `LifetimeHandle.luau`(quad-base, 인터페이스만)를 M2(Dispatch +엔진) 또는 M3(Store/State)로 옮기고, M8은 "quad-roblox 실제 구현(Instance +`Connected` 기반)"만 담당하도록 분리. M1 mock에도 이 인터페이스의 트리비얼 +스텁(항상 true)을 붙여두면 M4/M6 테스트가 자연스러워짐. + +### 1-10. `store.key`의 레코드 필드 타이핑 검증이 M0가 아니라 M3로 밀려 있음 + +**위치**: `ROADMAP.md` M0 vs M3 `"store.key dot-access 타입 추론 확인"`. + +**문제**: M0의 정의 자체가 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 +없는 것"을 검증하는 단계다. `base/store-semantics.md`가 요청한 M0 항목( +"Source가 State를 만족하는 제네릭 메소드 체이닝"의 솔버 안정성)은 이미 +반영됐지만, 이건 `:Compute` 같은 제네릭 메소드 체이닝만 다루고 `{key: +Source}` 같은 **레코드 필드로서의 dot-access 타이핑**(읽기/쓰기 +대칭성 논거의 핵심 전제)은 별개로 M3에 남아있다. 같은 리스크 카테고리인데 +M1(스캐폴딩)·M2(디스패치 엔진) 투자가 먼저 이뤄진 뒤에야 검증되는 셈이라, +여기서 걸리면 이미 만든 스캐폴딩/디스패치 타입 시그니처를 다시 손봐야 할 +수 있음. + +**제안**: M0 항목에 "`store.key`가 실제로 `Source` 레코드 필드로 +안전하게 추론되는지"도 같이 넣을 것 — 어차피 같은 스파이크 파일에서 몇 줄 +추가로 검증 가능. + +### 1-11. Modifier의 "제네릭 `__index` + `table.clone` 메타테이블 보존" 트릭이 검증 안 된 채 M7 전체 설계의 전제가 됨 + +**위치**: `base/modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 +하나만 있으면 됨" 절, `ROADMAP.md` M7. + +**문제**: M7의 핵심 주장("base에 제네릭 `__index` 하나면 충분, FrameModifier +류는 순전히 정적 타입 체크용")은 `mod:FontSize(14)` → `__index(self, +"FontSize")`가 즉석 클로저를 리턴하고, `table.clone`이 메타테이블을 그대로 +복사해줘서 체이닝이 안 끊긴다는 두 가지 Luau 동작에 전적으로 의존한다. +문서 자체가 "핵심 통찰"이라 부르지만 실제 Luau 코드로 확인된 적은 없다. +이게 틀리면 M7에서 "클래스별 런타임 코드 불필요"라는 설계가 무너지고 +필드별 정적 등록 방식으로 되돌아가야 하는데, M7은 M0~M6 다 끝난 뒤라 +되돌릴 때 비용이 큼. + +**제안**: 검증 비용이 낮음(Modifier 없이도 순수 메타테이블 실험 몇 줄로 +가능) — M0 스파이크 후보에 추가하거나, 최소한 "M7 착수 시점에 제일 먼저 +확인"이라고 `ROADMAP.md`에 명시. + +--- + +## 우선순위 2 — 지금 결정해두면 싼 것 (지연 결정 리스크) + +### 2-1. Source가 State를 만족하는 제네릭 검증이 실패했을 때의 대안(Plan B)이 전혀 없음 + +**위치**: `base/store-semantics.md` "Source가 State를 만족함" 절 — +"검증 필요(확정 아님, M0 스파이크 대상)... 다만 이것도 추론이라 실제 +Luau로 확인 전엔 확정 아님." + +**문제**: 검증 필요성 자체는 이미 M0 항목이라 새 지적 아니지만, **검증이 +실패했을 때 뭘 하는지가 문서 어디에도 없다.** 이 타입 구조(Source⊂State +서브타입)는 `store.key`의 반환 타입, `:Set()` 문법, `:Emit()`의 위치, +dot-access 타입추론, `RefSource` 폐기 결정까지 전부 이 위에 얹혀 있어서, +Luau 솔버가 막히면 되돌릴 범위가 `store-semantics.md`의 절반 이상에 걸침. + +**제안**: M0 스파이크 계획에 "실패 시 폴백은 RefSource 부활 vs 다른 대안" +한 줄이라도 미리 박아두면, 실패했을 때 다시 사용자 자문을 구하느라 멈추는 +걸 막을 수 있음. + +### 2-2. `State` 타입 차단이 Luau에서 실제로 가능한지 검증 계획이 없음 + +**위치**: `base/modifier-plan.md` 7번. + +**문제**: "가능하면 타입 시스템으로 아예 못 넣게 막을 것"이라 확정했지만, +Luau 제네릭은 "T가 특정 타입이면 거부"하는 부정 제약을 기본 지원하지 +않는다. `store-semantics.md`는 이보다 단순한 `Source satisfies +State` 조합조차 M0 스파이크 대상(솔버가 죽을 수 있음)으로 잡아뒀는데, +이보다 어려운 문제(제네릭 타입 파라미터 배제 제약)인 `State` +차단은 어디에도 검증 대상으로 언급되지 않는다. 실제로 안 되면 "UB, +가능하면 타입 차단"이 조용히 "그냥 UB, 런타임 가드 없음"으로 후퇴하는데 +그 fallback도 안 적혀있음. + +**제안**: `ROADMAP.md` M0(또는 M7 착수 시점)에 이 케이스를 포함하거나, +최소한 `modifier-plan.md`에 "타입 차단이 Luau에서 불가능하면 순수 UB로 +폴백"이라는 명시적 fallback 문장을 추가할 것. + +### 2-3. Component 래퍼 필요 여부가 "이름만 남음"으로 후순위 처리됐지만 구조적 결정일 가능성 + +**위치**: `base/component-composition-plan.md` "남은 열린 질문" — +"`Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 자체도 불확실 — +아마 불필요)". + +**문제**: 다른 순수 네이밍 항목들과 동급의 후순위로 묶여 있지만, 이건 이름 +문제가 아니라 **구조 문제**일 수 있다 — 래퍼가 있고 없고에 따라 (a) 정적 +타입 체크 지점(`props.Modifier`/`props.Ref` 필수 필드 검증을 어디서 +강제할지), (b) `quad-debug`가 요구하는 컴파일타임 소스 위치 주입(darklua) +훅 지점이 "함수 정의부"가 되는지 "매 호출부"가 되는지가 갈린다. 나중에 +"역시 얇은 래퍼가 필요하다"로 뒤집히면, 이미 "그냥 함수" 규약으로 짜인 +기존 컴포넌트 전체를 마이그레이션해야 하는 연쇄가 발생. + +**제안**: `CLAUDE.md`가 이미 M2/M3/M5에서 quad-debug 훅 확장 지점을 +고려하라고 명시해뒀으니, 그 시점에 이 질문도 같이 열어 "래퍼 없음"이 +구조적으로도 최종 확정인지 한 번 더 확인할 것. M1 스캐폴딩 전에. + +### 2-4. existing-instance-bind가 Slot의 "엄격한 단일 마운트 소유권" 불변식과 근본적으로 긴장 + +**위치**: `research/existing-instance-bind-plan.md` 전체 vs `base/ +slot-plan.md` "핵심 제약: 소유권 귀속과 단일 마운트". + +**문제**: 문서가 스스로 언급한 긴장(Modifier flatten의 clone 비용)과는 +별개로 더 근본적인 긴장이 있다. `retract`는 "이전에 자신이 process한 것을 +무른다"는 전제(quad가 자신이 만든 Instance를 생명주기 끝까지 들고 있는 +소유자)로 설계됐는데, existing-instance-bind는 정의상 **quad가 한 번도 +process한 적 없는 인스턴스**에 처음 바인드하는 시나리오다. 특히 Slot을 이 +인스턴스의 children 제어에 쓰려는 경우 — 기존에 손으로 만들어둔 자식들이 +Slot의 "own"한 대상인지 아닌지가 완전히 미정. 문서는 "핸들러 레지스트리가 +이미 우선순위 스캔 후 bind 구조라 재바인드도 같은 경로를 타면 됨"이라고 +낙관하지만, 이건 "새 값을 process하는 법"만 있으면 된다는 얘기고 "이 +인스턴스에 대해 quad가 이전에 뭘 소유했었는지 모르는 상태에서 안전하게 +재바인드하는 법"은 다른 문제다. 나중에 "기존 children을 흡수(adopt)하는 +API"를 얹어야 할 때 Slot의 "own한 것만 CRUD 대상" 불변식 자체를 건드려야 +할 수 있음. + +**제안**: 착수 안 해도 되지만, "이 기능이 실제로 필요해지면 Slot의 +소유권 모델에 '흡수(adopt)' 개념을 추가해야 할 수도 있다"는 캐비엇을 +`existing-instance-bind-plan.md`에 한 줄 추가해둘 것 — 기존 "Modifier +flatten과 긴장" 캐비엇 옆에 병기. + +### 2-5. `Modifier.Merge` 시 서로 다른 클래스의 Modifier가 섞이는 게 허용되는지 미정 + +**위치**: `base/modifier-plan.md` "2. Merge 우선순위" + `base/ +component-composition-plan.md` 3번(`Modifier.Merge`). + +**문제**: `question.md`가 언급하는 `DI.FrameModifier`류 "클래스별 타입 +프리픽스"를 보면 Modifier는 대상 인스턴스 클래스별로 타입이 나뉜다. merge는 +"필드명 기준, 나중 게 이김"이라고만 확정돼 있는데 — `Modifier.Merge`의 +가장 그럴듯한 실사용 시나리오가 바로 "공통 테마 Modifier(여러 GuiObject +클래스에 걸친 공통 필드) + 클래스별 override Modifier"를 합치는 것이다. +두 Modifier가 정적으로 다른 타입이면 `Modifier.Merge` 시그니처가 애초에 +타입 에러로 막아주는지(같은 제네릭 파라미터만 merge 허용), 아니면 필드명만 +보고 런타임에 섞이는 순수 데이터 레이어라 다른 클래스끼리도 그냥 합쳐지는 +지가 전혀 명시돼 있지 않다. + +**제안**: Modifier가 target 클래스별 제네릭 타입(`Modifier` 등)인지, +있다면 계층 구조(공통 base + 클래스별 확장)가 뭔지, `Modifier.Merge`가 +이걸 타입으로 강제하는지 최소 한 문장으로 확정할 것. 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.Merge`) vs +"Ref는... 별도 결합 유틸 불필요" 문장. + +**문제**: Modifier는 여러 개를 합치려면 `Modifier.Merge`가 명시적으로 +필요한데, 바로 다음 문장은 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 소유가 맞지만, 클래스별 +정적 타입 안전성(`Modifier.Rounded(8)`가 `FrameModifier` 타입으로 추론되는 +것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴 +재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게 +결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch, +`Modifier.Merge`, `State` 차단)엔 이 클래스별 타입 생성 작업이 +전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음. + +**제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier +생성자(FrameModifier 등)" 항목을 명시적으로 추가해 누락을 막을 것. + +### 2-9. 컴포넌트가 `props.Modifier`를 받아놓고 forward 안 하면 조용히 드롭됨 — 원칙 명문화 필요 + +**위치**: `base/component-composition-plan.md` "최종 결론" 1번, 3번. + +**문제**: 저작자가 `props.Modifier`를 실수로(또는 의도적으로) 내부 +`Frame{...}` 호출에 안 꽂아 넣으면 caller가 넘긴 modifier/ref는 조용히 +사라진다. 타입 시그니처에 `props.Modifier: Modifier`가 선언돼 있어도 +**실제로 그걸 쓰는지는 런타임/타입 어느 쪽도 강제 안 함** — "받았는데 +안 쓰는" 실패 모드가 별도로 존재. + +**제안**: 최소한 "무시되면 조용히 드롭된다(UB, 방어 로직 없음)"는 원칙을 +명시적으로 못박을 것. 방어할 가치가 있다고 판단되면 컴파일타임 린트 +(darklua) 후보로 `quad-debug`/문서화 백로그에 메모. + +### 2-10. Tween 자연완료(Completed) 시 per-instance 북키핑 정리 여부가 명세 안 됨 + +**위치**: `research/tween-plan.md` "`retract`(구 cleanup)로 확정된 +오버라이드 시맨틱" 절. + +**문제**: "새 값이 들어와 갈아치울 때"의 `retract` 동작(4가지 옵션, 기본값 +Cancel)은 상세히 정의했지만, **Tween이 사용자 개입 없이 스스로 끝까지 +재생되어 자연 완료된 경우** per-instance weak-keyed 저장소에 남아있는 +"이전 Tween 객체" 참조를 어떻게 다루는지는 언급이 없다. 남은 세 오버라이드 +옵션(override-without-delete/delete-then-restart/move-to-end) 각각이 +"이전 Tween이 아직 재생 중인가, 이미 끝났는가"에 따라 동작이 갈릴 수 +있는데(예: "끝점으로 옮기고 새 트윈 시작"은 이미 완료된 Tween엔 의미가 +이상해짐), 이 구분 로직 자체가 설계에 없음. + +**제안**: Tween 핸들러가 생성한 Tween의 `Completed` 이벤트를 구독해 +per-instance 저장소를 정리(또는 상태 플래그 갱신)하는지 여부를 명시. +M11 착수 시. + +### 2-11. UI shorthand — 기존 UICorner 자식과의 매칭 기준(이름 vs 타입)이 불명, 사용자 실수 유발 위험 높음 + +**위치**: `research/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로 만든 것일 수 있음 + +**위치**: `base/bind-system-plan.md` "`:Compute(fn)`의 선택적 두 번째 +인자 — `previous`" 절. + +**문제**: quad는 "렌더 함수가 계속 재실행되지 않고, `Compute`에 전달한 +함수 자체가 한 번 등록되어 재계산마다 그 동일 클로저가 재호출된다"는 +모델(문서 자체가 명시)다. 그렇다면 사용자가 `fn` 바깥에 `local prev` +업밸류를 두고 `fn` 안에서 그걸 읽고 갱신하면, 별도 `previous` 파라미터 +없이도 정확히 같은 "직전 반환값 재사용" 효과를 순수 Lua 문법만으로 얻을 +수 있어 보인다. 그런데 이 문서는 `previous`를 **별도 API 표면**(두 번째 +인자)으로 만들었고, "능동적으로 계속 관측되지 않으면 조용히 영구 +정지한다"는 상당히 위험한 캐비엇까지 별도로 문서화해야 할 만큼 무거운 +기능이다. 왜 클로저 업밸류로 충분하지 않은지 근거가 안 보임. + +**제안**: `previous` 인자를 유지할 근거(예: 업밸류 방식보다 타입 추론이 +쉬워진다든가)가 있다면 한 줄 추가하고, 없다면 "그냥 클로저 업밸류를 +쓰라"는 문서화 패턴으로 대체해 API 표면 자체를 줄이는 걸 검토. + +### 3-2. Corner/PaddingAll/Scale 개별 Handler 대신 데이터 테이블 구동 단일 제네릭 Handler + +**위치**: `research/ui-shorthand-plan.md` "메커니즘 — 새 아키텍처 개념 +불필요" 절. + +**문제**: 문서는 "Corner/PaddingAll/Scale 같은 특수 키를 인식하는 +Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 값 하나 → +고정 이름 자식 찾기/생성 → 프로퍼티 세팅)의 Handler를 각각 만드는 +그림이다. 문서 자체가 "앞으로 비슷한 제안이 오면 이 선례를 따르라"고 +일반화하고 있어, 향후 비슷한 shorthand가 추가될 때마다 Handler 파일이 +선형으로 늘어나는 구조. + +**제안**: `{key -> {ChildClassName, ChildDefaultName, Property, wrap=fn}}` +형태의 룩업 테이블 하나로 구동되는 단일 `Handlers/InstanceShorthand.luau` +로 통합하는 안을 검토. 새 shorthand 키 추가가 "테이블에 항목 하나 추가"로 +끝나 M10 이후 유지보수 비용이 줄어듦. 강제 사항 아님, 구현 시점에 결정할 +정도의 사소한 개선 후보. + +--- + +## 문서 모순 — 발견 현황 + +### 이미 고침 (이번 세션) + +- `architecture.md`의 "Store/State/Source 온톨로지 확정 요약" 절이 + `store-semantics.md`의 최신 재구성(Source가 State를 만족, `store.key`가 + Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 — 이 감사 + 세션 도중 발견해 직접 정정(커밋 `4b839b0`에서 별도로 이미 반영됨을 뒤늦게 + 확인 — 같은 문제를 두 세션이 독립적으로 발견한 셈). +- `architecture.md` 소스트리의 `Store.luau`/`Ref.luau` 주석이 각각 옛 + `__newindex` 모델, 옛 "Ref=CreatedRef 자체" 정의를 그대로 담고 있던 것 — + 이번 세션에서 직접 정정. + +### 아직 안 고침 (판단 필요해서 여기 남김) + +- **`State` 타입 차단(엔지니어링 비용 감수) vs Ref/Slot이 + Modifier 필드에 들어가는 건 UB로 방치 — 같은 문서 안에서 정반대 원칙이 + 나란히 적용됨.** `base/modifier-plan.md` "Modifier는 핸들러 계층을 + 모름" 절은 "권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는 + UB로 남겨둠"이라 명시적으로 방어를 포기했는데, 바로 옆 7번 절은 + `State` 조합을 "UB로 확정, **가능하면 타입 시스템으로 아예 못 + 넣게 막을 것**"이라며 정반대로 엔지니어링 비용을 들여 방어하기로 했다. + 두 결정 다 나름의 근거(후자는 "State에 담기면 재-flatten이 필요해져서 + 정적 merge 전제와 정면 충돌"이라 더 위험하다는 논리로 보임)가 있어 + 보이지만, 문서 어디에도 "왜 이 경우엔 원칙에서 예외로 처리하는가"를 + 명시적으로 인정/정당화하지 않고 그냥 나란히 적혀 있다. 위 2-2 항목 + (Luau에서 실제 차단 가능한지)과 묶어서 같이 정리할 문제. +- **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로 검증"하는 단계라고 + 명시돼 있어 뒤늦은 검증이 아니라 의도된 정식화. +- `PerInstanceState` 실제 구현 시점(M8) — 이걸 필요로 하는 핸들러(Tag/ + Attribute/Tween)가 전부 M10/M11이라 순서상 문제없음. +- Slot의 store-bind 의존(M6→M4) 순서. + +--- + +## 다음 액션 제안 + +- **M0 착수 전**: 1-5(props.Modifier/Ref nil-hole)를 M0 스파이크 코드에 + 반영, 1-10(`store.key` 레코드 필드 타이핑)을 M0로 앞당기는 것 검토, + 1-11(Modifier `__index` 트릭)도 비용이 낮으니 M0 후보로 포함 검토. +- **M2(Dispatch) 착수 전**: 1-2, 1-3, 1-4를 한 번에 확정(전부 base + dispatch 엔진의 에러/상태관리 규칙이라 같이 결정하는 게 효율적). +- **M2/M3 착수 전**: 1-6(canExecute 실제 구현) 실측, 1-9(LifetimeHandle + 마일스톤 재배치). +- **나머지**: 해당 마일스톤 착수 시점에 이 문서를 다시 열어 관련 항목만 + 확인하면 됨 — 지금 전부 결정할 필요는 없음. diff --git a/CLAUDE.md b/CLAUDE.md index 695e0fa..e5f642c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,11 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 quad-base용 최소 mock 테스트 하네스)으로 진행 — 소스 트리 자체는 이미 확정됨(`base/architecture.md` "구현 착수" 절). 이 단계부터 `qa-request/`/`archive/` 폴더가 실제로 쓰이기 시작함. **세부 순서/todo는 - `ROADMAP.md`가 소스** — 여기서 반복 안 함. + `ROADMAP.md`가 소스** — 여기서 반복 안 함. **M0 착수 직전에 `research/ + pre-implementation-audit.md`(2026-08-06 신설)의 우선순위1 항목부터 먼저 + 확인할 것** — 특히 M0 스파이크 코드 자체에 반영해야 할 항목(props.Modifier/ + Ref를 안 넘기는 케이스 포함, `store.key` 레코드 필드 타이핑도 M0로 + 앞당기기 검토)이 있음, 아래 최신 세션 요약 참고. 2. **용어 정리 — 사용자가 별도로 요청, 진행 중.** "register"(v1) 같이 부정확한 이름들을 전체적으로 재검토하자는 요청 — 1차 제안 완료(우선순위 순: `State`가 React/Vue식 "쓸 수 있는 로컬 상태"라는 통상 의미와 반대라 @@ -502,3 +506,66 @@ documentation-plan.md`/`framework-comparison-findings.md`의 "다음 단계"에 documentation-content-map.md`/`.claude/README.md`(참조 갱신). 이름 자체(`Source`/`State`)는 여느 때처럼 "지금 할 일" 2번 용어 정리 라운드까지 가칭. + +## 2026-08-06 네 번째 세션 — M0 착수 직전 크리티컬 감사, `research/pre-implementation-audit.md` 신설 + +사용자 요청: "실 개발 시 모호하여 인터럽트될 수 있는 부분, 나중에 결정되면 +치명적일 것 같은 것, 지금 구조가 오버엔지니어링일 수 있어 보이며 더 나은 +대안이 있는 것"을 찾아 정리해달라는 요청. `.claude/base/` 전체(architecture/ +bind-system/store-semantics/module-lifecycle/component-composition/ +modifier/purity-and-effects/slot/lifecycle-pattern/quad-v1-architecture)와 +근접 `research/`(existing-instance-bind/tween/ui-shorthand) + `ROADMAP.md`를 +4개 클러스터로 나눠 서브에이전트 4개를 병렬로 돌려 "모호성/지연결정리스크/ +단순화후보" 세 렌즈로 재감사, 결과를 `research/pre-implementation-audit.md` +로 종합. `.claude/question.md`엔 이미 취합된 것(용어 재검토, M0 스파이크 +항목 자체 등)과 겹치지 않는 새 발견만 반영. + +**작업 도중 발견한 부수 이슈**: 워크트리 생성 시점과 main 체크아웃의 +미커밋 변경사항(세 번째 세션 결과물)이 어긋나 있었음 — 워크트리는 커밋 +시점 기준으로 fork되므로 아직 커밋 안 된 변경은 안 딸려옴. 사용자가 중간에 +main에 커밋을 완료해줘서(`4b839b0`) 워크트리를 새로 만들어 재동기화함 — +**앞으로 워크트리에서 최신 설계를 감사/참조해야 하는 작업을 시작하기 전엔, +main에 미커밋 변경이 있는지(`git status`) 먼저 확인하고 필요하면 커밋을 +요청하거나 파일을 직접 동기화할 것.** + +**핵심 발견 요약** (전체 25개 항목은 `pre-implementation-audit.md` 참고, +우선순위1만 발췌): + +- **Tween.luau가 문서 전체에서 "범용 store-bind 캐치올 핸들러"의 유일한 + 구체 예시로 서술됨** — 애니메이션 없는 일반 반응형 프로퍼티 바인딩이 + 실제로 Tween 파일을 거쳐가는지, 별도 범용 핸들러가 필요한지 확정 안 됨. + 가장 구조적인 발견 — 직접 `bind-system-plan.md` 67-79행을 재확인해 + agent 발견을 검증함. +- `props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴의 + nil-hole 함정(caller가 안 넘기면 `{nil, ref, child}`에서 뒤 항목까지 + 무시될 수 있음)에 그대로 노출 — M0 스파이크 코드에 이 케이스를 반드시 + 포함시켜야 함. +- `canExecute`/`Connected`의 실제 구현 방식이 미확정인 채 코어 전역 + (Slot/Observer/store-bind retract)에 이미 재사용 확정돼 있음. +- `LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그 인터 + 페이스를 전제로 서술돼 있음 — 로드맵 순서 역전, `ROADMAP.md` 조정 필요. +- retract 시 "이전에 실제로 매치됐던 핸들러" 추적 책임, 우선순위 스캔 + 동률/매치실패 처리, provider 미주입 상태 dispatch 호출 시 동작 — + 전부 M2(Dispatch 엔진) 착수 전 한 번에 결정하면 효율적인 것들. +- Slot의 `add`/`remove`/`clear` CRUD 의미론 자체가 정의 안 돼 있음, + "재마운트 시 throw"도 추적 대상(개별 element vs Slot 컨테이너)이 + 뭉뚱그려 서술됨 — 둘 다 M6 착수 전 확정 필요. + +**단순화 후보로 지적된 것 중 사용자 판단 필요**: `:Compute(fn)`의 +`previous` 두 번째 인자 — quad의 "함수 자체가 재호출되는" 모델상 클로저 +업밸류로 이미 되는 걸 별도 API 표면으로 만든 것일 수 있음(근거 불명). + +**문서모순으로 남겨둔 것**: `State`는 "UB, 가능하면 타입으로 +차단"인데 Ref/Slot이 Modifier 필드에 들어가는 건 "UB, 방어 로직 없음" — +같은 문서(`modifier-plan.md`) 안에서 정반대 원칙이 근거 설명 없이 나란히 +적용됨. 판단이 필요해 고치지 않고 감사 문서에만 남김. + +**부수적으로 직접 고친 stale 문서(판단 불필요한 순수 동기화)**: `base/ +architecture.md` 소스트리 주석 두 곳 — `Store.luau`가 여전히 옛 `__newindex` +모델을 언급, `Ref.luau`가 여전히 "CreatedRef 메커니즘 자체"로만 서술(Ref +일반화 결정 반영 안 됨). 온톨로지 요약 절 stale은 같은 세션 도중 커밋 +`4b839b0`에서 이미 독립적으로 고쳐져 있었음을 확인 — 재작업 없이 스킵. + +**다음 세션이 할 일**: M0 착수 전에 `pre-implementation-audit.md` 우선순위1 +항목(특히 위 6개)부터 확인 — "지금 할 일" 1번 참고. `.claude/question.md` +2번에 사용자 판단이 필요한 항목 요약이 반영돼 있음. From ec5fb8184bfe52338486fdbf4763e0cfb69e1463 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 21:59:23 +0900 Subject: [PATCH 21/62] =?UTF-8?q?research:=20=ED=82=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EC=9E=AC=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=20=EC=84=A4=EA=B3=84=20=EC=8A=A4=EC=BC=80=EC=B9=98,?= =?UTF-8?q?=20Context=20=EB=82=9C=EC=9D=B4=EB=8F=84=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=20=EC=A7=84=ED=96=89=EC=A4=91=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자와 라이브 논의 반영 — state:Keyed(keyFn, renderFn) -> Slot 스케치(독립 프리미티브 vs 파생 데이터 원칙 적용해 메소드 프레이밍 채택), Context는 서브에이전트에게 구현 난이도 평가 위임 중. Co-Authored-By: Claude Sonnet 5 --- .../research/additional-primitives-plan.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index c65a5e6..42c9d61 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -179,6 +179,67 @@ context 테이블 전달" 패턴은 확인 못 함(급하지 않다는 방증). 충분히 대체), 다만 "재사용 가능 컴포넌트 라이브러리"를 장기 목표로 본다면 재검토 가치 — `purity-and-effects-plan.md`와 같이 봐야 함. +## 진행 중 논의 (2026-08-06 후속 세션) + +메인 브랜치에 병합된 `pre-implementation-audit.md`의 1-7번("Slot의 +`add`/`remove`/`clear` CRUD 의미론이 정의돼 있지 않음")과 정확히 맞물리는 +타이밍이라, 키 기반 컬렉션 재조정을 Slot 설계에 바로 접목해보는 논의 시작. + +### 키 기반 컬렉션 재조정 — 설계 스케치 (확정 아님) + +사용자가 처음부터 "Slot의 상위 요소"로 만들고 싶다고 명시 — 두 가지 프레이밍을 +제시함: (a) Slot을 만들어주는 팩토리 함수, (b) State의 "터미널 연산"(스트림의 +`.collect()`류)으로 Slot을 얻는 것(`state:?() -> Slot`). + +**두 프레이밍 중 (b)가 quad의 기존 원칙과 더 잘 맞음** — 2026-08-06 세션에서 +이미 확정된 "독립 프리미티브 vs 원천 종속 파생 데이터" 원칙(`state:Observer(fn)`가 +메소드고 자유 함수가 아닌 이유와 같은 논리, `store-semantics.md`)을 그대로 +적용하면: 키 기반 Slot은 그 뒤에 있는 State 없이는 존재 의미가 없는 **파생 +데이터**이므로, 자유 함수 팩토리(`Type(args)` 패턴)가 아니라 **State의 +메소드**로 두는 게 일관적. 가칭 `state:Keyed(keyFn, renderFn) -> Slot`. + +**메커니즘 스케치**(전부 이미 확정된 조각들의 재조합 — 새 마운트/디스패치 +장치 불필요): +- 내부적으로 `state:Observer(fn)`과 동형 — `fn`이 무효화 신호를 받을 때마다 + `Get()`으로 새 테이블을 pull하고, 이전에 본 key 집합과 diff. +- 새 key → `renderFn(key, itemState)` 호출 후 `Slot:add(...)`. +- 사라진 key → `Slot:remove(...)`(기존 확정 시맨틱대로 `retract`=폐기, 옮기지 + 않음 — `slot-plan.md`와 자동으로 일관됨). +- 유지되는 key → Slot 조작 없이 그 항목의 `itemState`에만 새 값을 반영(재생성 + 안 함) — Fusion `SubObject`/Vide `values()`가 하는 "값만 갱신"과 동일 효과를, + `renderFn`이 `itemState: State`를 받는 것만으로 자연스럽게 얻음(항목 값이 + 바뀌어도 renderFn 자체는 재호출 안 되고, `itemState`를 구독한 리프만 + 갱신됨). +- 소유권: 반환된 Slot은 기존 "엄격한 단일 마운트" 규칙 그대로 적용 — 새 규칙 + 불필요. +- 패키지 경계: diff 알고리즘(순수 데이터 로직)은 `quad-base`, 실제 Instance + 생성/제거는 기존 Slot 핸들러 경로 그대로 재사용 — `slot-plan.md`가 이미 + 확정한 base/roblox 분리와 동일 패턴. + +**열린 세부**: Fusion은 `ForPairs`/`ForKeys`/`ForValues` 3종으로 나뉘는데, +quad는 `renderFn(key, itemState)`가 항상 key+itemState를 다 주고 안 쓰는 +쪽은 그냥 무시하게 하는 **1종 통합안**이 단순화 후보로 보임(3개로 쪼갤 +근거가 약해 보임 — `pre-implementation-audit.md`의 "단순화 후보" 렌즈와 +같은 결). `keyFn` 생략 시 입력이 이미 맵이면 맵 key를 그대로 identity로 +쓰는 것도 자연스러운 기본값 후보. 이름(`Keyed`/`Each`/`List`/`ToSlot`)은 +용어 정리 라운드로 이월. + +**다음 단계**: 사용자 피드백 반영해 스케치 다듬고, 이견 없으면 M6(Slot) +착수 시점에 `slot-plan.md`/`bind-system-plan.md`에 정식 반영. + +### Context — 구현 난이도 판정 진행 중 + +사용자 확인: Context는 React `useContext`(Provider가 위에서 값을 심고, +하위 어디서든 prop 없이 읽는 패턴)와 같은 개념 맞음. 구현 난이도가 +채택 여부를 사실상 결정한다는 사용자 판단에 따라 서브에이전트에게 +난이도 평가 위임(진행 중) — 특히 "컴포넌트 호출 자체는 동기적 콜스택으로 +Context 전파가 가능해 보이지만, Slot에 나중에(이벤트 핸들러/코루틴에서) +비동기로 추가되는 자식까지 자동으로 따라오게 하는 건 훨씬 어려워 보인다"는 +사용자 직관을 검증 대상으로 명시. 결과는 후속 라운드에 반영. + +사용자가 제시한 대안(Store 병합, 타입 안전 서브셋 전달, 레이어 계층을 가진 +Store)도 같은 서브에이전트에게 난이도/효용 비교를 같이 시킴. + ## 참고: 조사에 사용한 소스 근거 - Fusion: `State/ForPairs.luau`, `State/ForKeys.luau`, From 67438a172e5fb9be16c5f988816df3597454ccf9 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 22:04:54 +0900 Subject: [PATCH 22/62] =?UTF-8?q?research:=20Context=20=EB=82=9C=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=20=ED=8C=90=EC=A0=95=20=EC=99=84=EB=A3=8C=20=E2=80=94?= =?UTF-8?q?=20=EA=B8=B0=EA=B0=81=20=EA=B6=8C=EA=B3=A0,=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=93=9C=20Store=20=EB=8C=80=EC=95=88=20?= =?UTF-8?q?=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서브에이전트 조사: 동기 콜스택 한정 버전은 구현 난이도 낮지만(Fusion Contextual 이식 가능), quad가 정상 패턴으로 확정한 Slot 비동기 추가에서 조용히 defaultValue로 폴백하는 함정 있음 + quad-debug의 "모든 연결은 선언된 그래프" 철학과 충돌. Roblox Luau는 thread-local이 없어 완전 자동 버전은 플랫폼 한계로 사실상 불가(Node AsyncLocalStorage/Python contextvars와 동일 문제). 대안 비교 결과 레이어드 Store(__index 델리게이션, Modifier/ Source가 이미 쓰는 패턴과 동일 계열)가 서브트리 오버라이드 가치를 명시적 전달 철학·비동기 안전성 유지하며 대부분 재현 — 최종 권고안으로 채택. Co-Authored-By: Claude Sonnet 5 --- .claude/question.md | 23 ++-- .../research/additional-primitives-plan.md | 101 ++++++++++++------ 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/.claude/question.md b/.claude/question.md index a3bfbfa..75aff93 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -17,15 +17,22 @@ Fusion/Vide/quad v1 소스를 서브에이전트 2개로 병렬 조사 완료, 상세는 `research/additional-primitives-plan.md`. 요지: -- **키 기반 동적 컬렉션 재조정(가장 시급)**: Fusion `ForPairs`/`ForKeys`/ - `ForValues`, Vide `indexes()`/`values()`, React `key` prop에 대응하는 - 프리미티브가 quad엔 전혀 없음 확인 — `Slot`은 CRUD 껍데기일 뿐 diff - 엔진이 아님. 인벤토리/리더보드/채팅로그 같은 실전 리스트 UI에 직결. - Slot 확장으로 갈지 별도 프리미티브(가칭 `Keyed`/`ForEach`)로 갈지부터 - 전혀 정해진 게 없음 — 사용자 판단 필요. +- **키 기반 동적 컬렉션 재조정(가장 시급, 설계 스케치 진행 중)**: Fusion + `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, React + `key` prop에 대응하는 프리미티브가 quad엔 전혀 없음 확인 — `Slot`은 + CRUD 껍데기일 뿐 diff 엔진이 아님(`pre-implementation-audit.md` 1-7번과 + 같은 지점). 가칭 `state:Keyed(keyFn, renderFn) -> Slot`(State의 파생 + 데이터 메소드로 프레이밍, `state:Observer(fn)`와 같은 논리) 스케치 + 진행 중 — 사용자 피드백 반영 중, 상세는 `research/additional-primitives-plan.md` + "진행 중 논의" 절. - Effect/Watch(자동 cleanup 공개 API), Batch/Transaction(이벤트 store-bind - churn 문제 직결), Context(트리 전파, 단 `purity-and-effects-plan.md` - 이식성 원칙과 상충)는 부차적 후보로 확인, 착수 여부 미정. + churn 문제 직결)은 부차적 후보로 확인, 착수 여부 미정. +- **Context — 기각 권고로 결론(2026-08-06, 서브에이전트 난이도 판정 + 완료)**: 완전 자동 버전은 Roblox Luau 플랫폼 한계로 사실상 불가, 얕은 + 버전(동기 콜스택 한정)도 quad가 정상 패턴으로 확정한 Slot 비동기 추가에서 + 조용히 깨짐 + quad-debug의 "모든 연결은 선언된 그래프" 철학과 충돌 — + 대신 **레이어드 Store**(`__index` 델리게이션 기반 서브트리 오버라이드)를 + 실제 필요 시점에 검토할 것으로 대체 결정. - Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단. diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 42c9d61..e44dfc6 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -33,7 +33,7 @@ Fusion/Vide/react-lua 강점/약점 비교)와는 다른 질문 — 그 문서 | 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리** | 높음 — 가장 시급 | | Effect/Watch(자동 cleanup 포함 사이드이펙트) | 진짜 빈 자리 | 중간 | | Batch/Transaction | 부분적 빈 자리(이미 문서화된 churn 문제와 직결) | 중~낮 | -| Context(트리 하위 암묵 전파) | 부분적 빈 자리, 철학과 상충 | 낮음 | +| Context(트리 하위 암묵 전파) | **기각 권고**(난이도 판정 완료, 2026-08-06) — 레이어드 Store로 대체 | - | | Untrack/Peek | 빈 자리 아님 | - | | Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | - | | Error Boundary | 빈 자리 아님 | - | @@ -122,25 +122,31 @@ State에 이벤트를 직접 물리면... churn 비용"). 즉 여러 Source가 ` Vide `batch.luau:4-21`가 정확히 이 역할(여러 `source:set()`을 하나의 flush로 묶음). -## 4. Context(트리 하위 암묵 전파) — 부분적 빈 자리, 철학과 상충 +## 4. Context(트리 하위 암묵 전파) — 난이도 판정 완료, 기각 권고 (2026-08-06) -Fusion `Utility/Contextual.luau:28-88`(코루틴 스택 기반 스코프 값), Vide -`context.luau:14-72`(scope 그래프 조회)가 대응 개념. quad는 컴포넌트 -경계를 named parameter로만 넘기기로 이미 확정했고(`component-composition-plan.md`), -Context는 본질적으로 "명시적 전달을 건너뛰는" 도구라 quad의 명시성 철학과 -다소 충돌함. +Fusion `Utility/Contextual.luau:28-88`(코루틴 키 weak table push-pop), Vide +`context.luau:14-72`(scope 그래프 조회)가 대응 개념. 서브에이전트에게 구현 +난이도 평가를 위임한 결과(상세 근거는 아래 "진행 중 논의" 절의 Context +서브섹션 참고), **동기적 저작 트리 안에서만 작동하는 얕은 버전은 구현 +난이도가 낮지만(Fusion 코드를 사실상 그대로 이식 가능), quad가 이미 +정상 패턴으로 확정한 "Slot에 이벤트 핸들러/코루틴에서 비동기로 자식이 +추가되는 경우"엔 조용히 기본값으로 폴백하는 함정이 생김**을 확인 — +Roblox Luau에 thread-local/async-context-propagation 훅이 없어 완전 +자동화는 사실상 불가능(플랫폼 한계, 구현 노력의 문제가 아님). Node +`AsyncLocalStorage`/Python `contextvars`가 같은 문제를 "자동"이 아니라 +"async 경계마다 명시적 캡처+재진입"으로 푸는 것과 동일한 결론. -Roblox ModuleScript의 `require()` 캐싱이 사실상 싱글톤 전역 접근점 역할을 -자연스럽게 하므로(`local Theme = Store({...})`를 모듈로 export해서 아무 -컴포넌트에서나 require해 직접 읽으면 됨), **단일 게임 내부 UI 시나리오에선 -이게 사실상 Context 대체재로 충분**함 — quad가 이미 허용하는 "Store는 -부작용 허용" 철학과도 맞음. 다만 (a) 서브트리별 스코프 분리가 안 되고, -(b) `purity-and-effects-plan.md`의 이식성 원칙과 정면 충돌함(여러 게임에 -배포할 재사용 가능한 컴포넌트 라이브러리를 만들려는 순간, 테마 하나 -넘기려고 모든 중간 레이어에 `props.Theme`를 수동으로 계속 꿰어야 하는 -전형적 prop-drilling이 그대로 남음). "라이브러리로서의 지속 가능성"이라는 -프로젝트 목표와는 긴장 관계 — artworks에서 이걸 뒷받침할 "수동 전역 -context 테이블 전달" 패턴은 확인 못 함(급하지 않다는 방증). +**기각 권고 근거**: 얕은 버전조차 (a) quad가 스스로 정상 패턴으로 확정한 +Slot 비동기 추가에서 가장 먼저 깨지고, (b) `research/debug-tooling-plan.md`의 +"모든 연결은 선언된 그래프여야 한다"는 quad-debug 철학과 충돌하는 안 보이는 +채널을 만듦. 대신 **레이어드 Store**(자식 Source 모음이 자기한테 없는 키는 +부모로 `__index` 폴백 — Modifier/Source가 이미 쓰는 델리게이션 패턴과 동일 +계열)가 Context의 핵심 가치(서브트리별 오버라이드)를 명시적 전달 철학과 +비동기 안전성을 유지하면서 대부분 재현함 — 오버라이드 Store 참조는 여전히 +prop으로 명시 전달해야 하지만, 이건 `component-composition-plan.md`가 이미 +확정한 비용이라 새로 감수하는 게 아님. Roblox `require()` 캐싱 싱글톤(단일 +전역, 서브트리 오버라이드 불가)은 오버라이드가 필요 없는 단순 케이스에는 +여전히 충분. ## 빈 자리 아닌 것으로 확인된 것들 @@ -175,9 +181,8 @@ context 테이블 전달" 패턴은 확인 못 함(급하지 않다는 방증). 2. **Effect 공개 API** — `process`/`retract`가 이미 증명한 패턴을 얇게 노출하는 정도라 구현 비용 낮음. 3. **Batch** — 이미 문서화된 churn 문제의 직접 해법, opt-in 유틸 수준. -4. **Context** — 급하지 않음(Roblox `require` 캐싱이 단일 게임 시나리오는 - 충분히 대체), 다만 "재사용 가능 컴포넌트 라이브러리"를 장기 목표로 - 본다면 재검토 가치 — `purity-and-effects-plan.md`와 같이 봐야 함. +4. **Context** — 기각 권고(2026-08-06 난이도 판정 완료). 서브트리 오버라이드가 + 실제로 필요해지면 Context가 아니라 **레이어드 Store**를 검토할 것. ## 진행 중 논의 (2026-08-06 후속 세션) @@ -227,18 +232,54 @@ quad는 `renderFn(key, itemState)`가 항상 key+itemState를 다 주고 안 쓰 **다음 단계**: 사용자 피드백 반영해 스케치 다듬고, 이견 없으면 M6(Slot) 착수 시점에 `slot-plan.md`/`bind-system-plan.md`에 정식 반영. -### Context — 구현 난이도 판정 진행 중 +### Context — 구현 난이도 판정 완료 (2026-08-06) 사용자 확인: Context는 React `useContext`(Provider가 위에서 값을 심고, -하위 어디서든 prop 없이 읽는 패턴)와 같은 개념 맞음. 구현 난이도가 -채택 여부를 사실상 결정한다는 사용자 판단에 따라 서브에이전트에게 -난이도 평가 위임(진행 중) — 특히 "컴포넌트 호출 자체는 동기적 콜스택으로 -Context 전파가 가능해 보이지만, Slot에 나중에(이벤트 핸들러/코루틴에서) -비동기로 추가되는 자식까지 자동으로 따라오게 하는 건 훨씬 어려워 보인다"는 -사용자 직관을 검증 대상으로 명시. 결과는 후속 라운드에 반영. +하위 어디서든 prop 없이 읽는 패턴)와 같은 개념 맞음. 구현 난이도가 채택 +여부를 사실상 결정한다는 사용자 판단에 따라 서브에이전트에게 난이도 평가를 +위임했고, 결과 수렴 완료 — **위 "4. Context" 절이 최종 결론**. 요지만 +재정리: -사용자가 제시한 대안(Store 병합, 타입 안전 서브셋 전달, 레이어 계층을 가진 -Store)도 같은 서브에이전트에게 난이도/효용 비교를 같이 시킴. +- **난이도 등급**: 동기적 저작 트리에 한정한 얕은 버전 = **낮음**(Fusion + `Contextual`의 "코루틴을 키로 하는 weak table push-pop"을 그대로 이식 + 가능, quad-base 단독으로 완결, quad-roblox 분리조차 불필요). 완전 + 자동(비동기 Slot 추가까지 자동 전파) = **매우 높음, 사실상 불가** — + Roblox Luau에 thread-local/async-context-propagation 훅이 없어서 quad가 + 소유하지 않는 임의의 콜백 경계(`Signal:Connect`, `task.spawn`, Promise)를 + 가로챌 방법이 없음. 이건 quad 설계의 문제가 아니라 플랫폼 자체의 한계. +- **사용자의 직관이 정확히 맞았음**: 동기 콜스택 부분은 싸지만, Slot에 + 나중에(이벤트 핸들러/코루틴에서) 비동기로 자식이 추가되는 케이스에서 + Fusion 방식은 **에러 없이 조용히 `defaultValue`로 폴백**함 — "테마가 + 가끔 기본값으로 보인다"는 형태의 원인 추적 어려운 버그를 만듦(Vide + 방식은 폴백 대신 에러를 던지지만, 애초에 이 시나리오를 지원 대상으로 + 설계한 적이 없어 참고할 답이 없기는 마찬가지). Node + `AsyncLocalStorage`/Python `contextvars`도 "자동"이 아니라 "async 경계마다 + 명시적으로 값을 캡처해서 재진입"하는 방식으로 같은 문제를 풀고 있어, + 이식해도 결국 "prop drilling 비용"이 "매 async 경계 캡처+재진입 보일러 + 플레이트 비용"으로 자리만 옮김. +- **기각 이유(효용 대비 비용)**: 얕은 버전조차 채택할 가치가 낮음 — (1) + `base/slot-plan.md`가 정상 패턴으로 명시한 Slot 비동기 추가에서 가장 + 먼저, 가장 조용히 깨짐, (2) `research/debug-tooling-plan.md`의 "모든 + 연결은 선언된 그래프여야 한다"는 quad-debug 철학과 충돌하는 안 보이는 + 채널을 만듦. +- **대안 비교 결과**: Store 병합(난이도 매우 낮음, 그러나 drilling 자체는 + 안 없앰) < 타입 안전 서브셋 전달(난이도 낮음, 하지만 트리 전파 문제와는 + 별개 축이라 해결책이 아님) < **레이어드 Store**(난이도 낮음~중간, Context의 + 핵심 가치인 "서브트리별 오버라이드"를 대부분 재현하면서 명시적 전달 + 철학·비동기 안전성 둘 다 유지 — Modifier의 제네릭 `__index` 트릭, + Source가 State를 만족시키는 `__index` 델리게이션과 같은 이미 검증된 + 패턴 계열이라 구현 리스크도 낮음) — **레이어드 Store가 최종 권고안.** + 사용자가 스스로 "어려울 것 같다"고 짐작했던 것과 달리, quad2-try에서 + 금지한 `Class:Extend()`류 행위(메소드) 상속과는 다른 층위(순수 데이터 + 값 조회의 `__index` 폴백일 뿐)라 실제로는 낮은 난이도로 판정됨 — 다만 + Luau 타입 레벨에서 "자식 키 ∪ 부모의 나머지 키"를 구조적으로 표현하는 + 부분은 M0가 이미 검증 대상으로 잡은 `Source satisfies State`류 + 솔버 위험과 비슷한 급이라, 도입한다면 같은 스파이크에 끼워 검증하는 게 + 합리적. +- **결론**: Context라는 이름의 범용 프리미티브는 채택하지 않음. 테마/로케일 + 서브트리 오버라이드가 실제로 필요해지는 시점에 레이어드 Store를 별도 + 후보로 검토(지금 착수 우선순위 낮음, 키 기반 컬렉션 재조정이 여전히 + 더 시급). ## 참고: 조사에 사용한 소스 근거 From 4358fa71d79aeb1c2d752738e9ee3c8de8aabf06 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 23:36:36 +0900 Subject: [PATCH 23/62] =?UTF-8?q?research:=20=ED=82=A4=20=EC=9E=AC?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20=EC=9E=90=EC=9C=A0=ED=95=A8=EC=88=98+Extra?= =?UTF-8?q?ct=EB=A1=9C=20=EC=A0=95=EC=A0=95,=20Effect=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=99=94,=20Batch/Context=20=EC=B5=9C=EC=A2=85=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 라이브 피드백 반영: - 키 기반 컬렉션 재조정: State 메소드 프레이밍 철회(Source 안 쓰는 컴포넌트가 못 씀), 자유 함수 + plain-or-State 폴리모픽 시그니처로 정정. Slot에 파괴 없이 빼내는 Extract 연산 필요(리오더용, 기존 "portal 없음" 결정과는 다른 층위라 안 부딪힘). 이름 후보(Render/Draw/List) 추가, Keyed는 탈락. - Effect: Observer에 cleanup 반환 계약 추가하는 안 기각(클로저 업밸류로 이미 충분 — pre-implementation-audit.md 3-1과 같은 논리). 대신 leaf 죽음에 확정 정리하는 별도 단순 primitive로 수렴, 시그니처만 남음. - Batch: 코루틴 yield 시나리오 분석 결과 lexical transaction 모델 자체가 구조적으로 위험 — 프리미티브로 안 만들기로 결정, 심화 최적화 팁 + quadnomicon 에세이로 대체. - Context: 기각 확정. 대안이던 레이어드 Store도 사용자 반박으로 철회(이미 있는 타입 강제 명시적 Store 전달 + 오버라이드 지점 명시적 병합으로 충분). documentation-content-map.md에 quadnomicon 에세이 후보 2개(왜 Batch가/ Context가 없는가) + 심화 최적화 팁 1개 추가. Co-Authored-By: Claude Sonnet 5 --- .claude/question.md | 51 +- .../research/additional-primitives-plan.md | 470 ++++++++++-------- .claude/research/documentation-content-map.md | 18 + 3 files changed, 310 insertions(+), 229 deletions(-) diff --git a/.claude/question.md b/.claude/question.md index 75aff93..42ed0fb 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -10,29 +10,36 @@ ## 지금 열려있는 것 (우선순위순) -### 0. 추가 프리미티브 필요성 — 사용자 요청, 조사 완료(2026-08-06) +### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06) 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 -같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 웹 프레임워크/ -Fusion/Vide/quad v1 소스를 서브에이전트 2개로 병렬 조사 완료, 상세는 -`research/additional-primitives-plan.md`. 요지: +같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 여러 서브에이전트 +조사 + 사용자와 라이브 논의로 계속 수렴 중, 상세는 `research/ +additional-primitives-plan.md`. 요지: -- **키 기반 동적 컬렉션 재조정(가장 시급, 설계 스케치 진행 중)**: Fusion +- **키 기반 동적 컬렉션 재조정(유일하게 아직 열려있음, 최우선)**: Fusion `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, React - `key` prop에 대응하는 프리미티브가 quad엔 전혀 없음 확인 — `Slot`은 - CRUD 껍데기일 뿐 diff 엔진이 아님(`pre-implementation-audit.md` 1-7번과 - 같은 지점). 가칭 `state:Keyed(keyFn, renderFn) -> Slot`(State의 파생 - 데이터 메소드로 프레이밍, `state:Observer(fn)`와 같은 논리) 스케치 - 진행 중 — 사용자 피드백 반영 중, 상세는 `research/additional-primitives-plan.md` - "진행 중 논의" 절. -- Effect/Watch(자동 cleanup 공개 API), Batch/Transaction(이벤트 store-bind - churn 문제 직결)은 부차적 후보로 확인, 착수 여부 미정. -- **Context — 기각 권고로 결론(2026-08-06, 서브에이전트 난이도 판정 - 완료)**: 완전 자동 버전은 Roblox Luau 플랫폼 한계로 사실상 불가, 얕은 - 버전(동기 콜스택 한정)도 quad가 정상 패턴으로 확정한 Slot 비동기 추가에서 - 조용히 깨짐 + quad-debug의 "모든 연결은 선언된 그래프" 철학과 충돌 — - 대신 **레이어드 Store**(`__index` 델리게이션 기반 서브트리 오버라이드)를 - 실제 필요 시점에 검토할 것으로 대체 결정. + `key` prop에 대응하는 프리미티브가 quad엔 전혀 없음 확인 — + `pre-implementation-audit.md` 1-7번(Slot CRUD 미정의)과 같은 지점이라 + 같이 정의해야 함. **자유 함수**(plain data 또는 State 둘 다 받는 + 폴리모픽 시그니처, State 메소드 프레이밍은 Source 안 쓰는 컴포넌트가 + 못 쓴다는 반례로 철회됨), Slot에 파괴 없이 빼내는 `Extract` 연산 추가 + 필요 — 최종 이름만 미정(아래 "용어 정리" 절에 후보 추가). **사용자가 + "작업 전에 모든 정의를 마치고 싶다"고 명시** — M0 이전 완전 확정 목표. +- **Effect — 거의 수렴**: leaf가 죽을 때 확정적으로 정리 콜백을 부르는 + 단순 primitive(재실행 개념 없음, Observer와 별개)로 합의, 시그니처만 + 남음. Observer에 cleanup 반환 계약을 얹는 안은 기각(클로저 업밸류로 + 이미 충분 — `pre-implementation-audit.md` 3-1과 같은 논리). +- **Batch/Transaction — 프리미티브로 안 만들기로 결정 완료**: lexical + transaction 블록이 코루틴 yield 위에서 구조적으로 위험(전역/코루틴 + 스코프 플래그 둘 다 새 코루틴 스폰이나 영구 yield에 깨짐) — 대신 + 심화 최적화 팁 + quadnomicon "왜 없는가" 에세이로 문서화. +- **Context — 기각 확정, 대안이던 레이어드 Store도 철회**: 완전 자동 + 버전은 Roblox Luau 플랫폼 한계로 사실상 불가, 얕은 버전도 Slot 비동기 + 추가에서 조용히 깨짐 + quad-debug 철학과 충돌. 레이어드 Store 대안은 + 사용자 반박으로 철회 — 이미 있는 타입 강제 명시적 Store 전달 + + 오버라이드 지점에서 새 Store를 만들어 넘기는 것으로 충분하다는 판단. + 둘 다 "왜 없는가" quadnomicon 에세이 대상. - Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단. @@ -68,6 +75,12 @@ Fusion/Vide/quad v1 소스를 서브에이전트 2개로 병렬 조사 완료, 통로"에서 "아무 사용자 값이나 담는 범용 값 박스"로 넓어져서(`base/ bind-system-plan.md` "Ref 일반화" 절), 이름이 여전히 넓어진 의미에 맞는지 재검토 대상. +- **키 기반 동적 컬렉션 재조정 프리미티브 이름(3순위, 2026-08-06 추가)**: + `Keyed`는 타이핑이 어색하고 "Slot을 렌더한다"는 느낌과 안 맞는다는 + 사용자 피드백으로 탈락. 후보: `Render`(가장 직접적이지만 "quad엔 렌더 + 주기가 없다"는 기존 원칙과 이름이 충돌해 보일 수 있음), `Draw`(짧지만 + 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 + 미정, `research/additional-primitives-plan.md` 1번 절 참고. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index e44dfc6..2d5be45 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -1,7 +1,9 @@ # 추가 프리미티브 필요성 조사 — 다른 프레임워크 대비 갭 분석 -**상태**: research — 조사 완료(2026-08-06), 사용자 판단 대기. 설계/구현 결정은 -전혀 안 됨, 이 문서는 "뭐가 빠졌을 수 있는지" 후보를 정리한 것뿐. +**상태**: research — 사용자와 라이브 논의로 계속 수렴 중(2026-08-06). Context/ +Batch는 **결정 완료**(둘 다 프리미티브로 안 만듦). 키 기반 컬렉션 재조정은 +설계 진행 중(사용자가 "작업 전에 모든 정의를 마치고 싶다"고 명시 — M0 전 +완전 확정이 목표). Effect는 거의 수렴, 시그니처만 남음. ## 배경 @@ -13,140 +15,276 @@ ## 조사 방법 -서브에이전트 2개를 병렬로 띄워 서로 다른 각도로 조사: -1. **웹 프레임워크 서베이** — React/Vue 3/Solid/Svelte 5/MobX 등 주류 - 반응형 프레임워크 기준, quad 프리미티브 집합에 빠진 개념이 있는지 일반 - 지식 기반 평가. -2. **Roblox 생태계 소스 기반 조사** — Fusion(`initreq/fusion/src`), - Vide(`initreq/vide/src`), quad v1(`initreq/quad/src`), PA님 실 프로덕션 - 코드(`initreq/artworks`)를 직접 읽고 파일:라인 근거로 검증. - -두 조사 모두 이미 있는 `research/framework-comparison-findings.md`(quad vs -Fusion/Vide/react-lua 강점/약점 비교)와는 다른 질문 — 그 문서는 "같은 -개념을 quad가 얼마나 잘 구현했는가"를 다뤘고, 이 문서는 "개념 자체가 -통째로 없는 게 있는가"를 다룸. +서브에이전트 여러 개를 병렬/순차로 띄워 조사(웹 프레임워크 서베이, Fusion/ +Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 결과를 +사용자와 라이브로 검증/반박/재조정. `research/framework-comparison-findings.md` +(quad vs Fusion/Vide/react-lua 강점/약점 비교)와는 다른 질문 — 그 문서는 +"같은 개념을 quad가 얼마나 잘 구현했는가", 이 문서는 "개념 자체가 통째로 +없는 게 있는가/필요한가". ## 결론 요약 -| 후보 | 판정 | 심각도 | +| 후보 | 판정 | 상태 | |---|---|---| -| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리** | 높음 — 가장 시급 | -| Effect/Watch(자동 cleanup 포함 사이드이펙트) | 진짜 빈 자리 | 중간 | -| Batch/Transaction | 부분적 빈 자리(이미 문서화된 churn 문제와 직결) | 중~낮 | -| Context(트리 하위 암묵 전파) | **기각 권고**(난이도 판정 완료, 2026-08-06) — 레이어드 Store로 대체 | - | +| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** | 설계 진행 중 | +| Effect(leaf 죽음에 확정 정리) | 진짜 빈 자리 | 거의 수렴, 시그니처만 남음 | +| Batch/Transaction | **프리미티브로 안 만듦** — 문서화로 대체 | 결정 완료 | +| Context(트리 하위 암묵 전파) | **기각** — 대안(레이어드 Store)도 철회 | 결정 완료 | +| Observer에 cleanup 반환 계약 추가 | **기각** — 클로저 업밸류로 이미 충분 | 결정 완료 | | Untrack/Peek | 빈 자리 아님 | - | | Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | - | | Error Boundary | 빈 자리 아님 | - | | Readonly wrapper | 빈 자리 아님 | - | -두 에이전트가 서로 독립적으로 **키 기반 리스트/컬렉션 재조정**을 가장 크고 -명확한 빈 자리로 지목했다는 점이 이 조사에서 가장 신뢰도 높은 결론. - -## 1. 키 기반 동적 컬렉션 재조정 — 진짜 빈 자리, 최우선 검토 대상 +## 1. 키 기반 동적 컬렉션 재조정 — 최우선, 설계 진행 중 **무엇인가**: 데이터 배열(인벤토리, 리더보드, 채팅로그처럼 삽입/삭제/ 재정렬되는 목록)을 UI로 렌더링할 때, 이전 렌더 결과와 새 데이터를 **정체성(key) 기준으로 diff**해서 변경분만 생성/갱신/파괴하는 프리미티브. -React `key` prop, Vue `v-for :key`, Solid ``가 이 층위. +React `key` prop, Vue `v-for :key`, Solid ``, Fusion `ForPairs`/ +`ForKeys`/`ForValues`, Vide `indexes()`/`values()`가 이 층위. `base/ +slot-plan.md`의 `Slot`은 CRUD 껍데기일 뿐 diff 엔진이 아니라서 이 프리미티브가 +quad엔 없음(확인 완료, 근거는 문서 하단 소스 목록 참고). -**Roblox 선례에 명확히 존재함**: -- Fusion `State/ForPairs.luau:46-89` — key/value 쌍마다 독립 `SubObject`를 - 만들어 안 바뀐 키는 재계산을 건너뜀. `ForKeys.luau`도 같은 `For` 코어 - 위에서 파라미터만 바꿔 변형. -- Vide `indexes.luau:11-119`, `values.luau:11-131` — 매 effect마다 - `scopes` 맵을 순회, 새 항목은 `branch()` 생성, 사라진 항목은 - `present(false)` 후 `destroy()`, 남은 항목은 값만 갱신(재생성 없음). +### 왜 "매핑 함수" 직관이 안 통하는가 -**quad엔 없음(확인 완료)**: `base/slot-plan.md`의 `Slot`은 `add`/`remove`/ -`clear`/`get`/`set` CRUD를 지원하는 **뮤터블 배열**일 뿐, "입력 데이터를 -주면 알아서 diff해서 CRUD를 호출해주는" 계층이 없음. `base/*.md` 전체를 -grep해도 `ForPairs`/`ForKeys`/`keyed`/`diffing`류 언급 전무. quad v1에도 -`diff`/`reconcile`류 헬퍼는 없었음(grep 확인) — v1 사용자도 이 문제를 -프레임워크 밖에서 손으로 풀어왔다는 정황. +React류는 매 렌더마다 새 가상 트리를 통째로 새로 만들고 reconciler가 +old/new를 diff한다 — 그래서 "그냥 다시 매핑"이 성립한다. quad는 컴포넌트가 +**한 번만 실행**되므로 그 "매 렌더"가 아예 없다. 그래서 이건 매핑 함수가 +아니라 **한 번 설치되면 스스로 diff-and-patch를 도는 Observer 변형**이다 — +전달한 `renderFn`은 새 key가 나타났을 때 딱 한 번만 불리고, 그 이후로는 +값이 바뀌어도 절대 다시 안 불린다. -**실전 영향**: 지금 구조로 동적 리스트를 만들려면 "이전 렌더된 항목을 -어딘가 들고 있다가 새 데이터와 직접 비교해 Slot의 add/remove를 손으로 -호출"하는 로직을 화면마다 재발명해야 함 — Fusion/Vide가 라이브러리 -차원에서 없애준 보일러플레이트(어떤 항목이 "같은 항목"인지, 순서가 바뀐 -항목을 삭제+재생성할지 in-place로 옮길지)가 quad엔 그대로 남음. Slot -자체는 "부모는 데이터 테이블만 다루면 됨"이라는 좋은 저수준 기반이라, -그 위에 diff 알고리즘 한 겹만 얹으면 되는 구조적으로 자연스러운 확장으로 -보임. +### 메커니즘 스케치 -**PA님 코드 정황 증거**: artworks엔 실제 UI 화면 코드가 없어(백엔드/OOP/ -데이터스토어 패턴 위주) 직접 증거는 못 찾았지만, `Utility/Array.luau`가 -`map`/`filter`/`insert`/`isEqual` 같은 범용 배열 유틸을 팀이 별도로 -만들어 쓰고 있었다는 점 자체가 "배열 반복 작업을 프리미티브로 뽑아내는" -습관이 있는 팀이라는 간접 방증. +``` +key가 새로 나타남 → renderFn(key, itemState) 호출, itemSource:Set(초기값), Slot에 삽입 +key가 사라짐 → Slot에서 제거(파괴) +key가 유지, 값만 변경 → renderFn 재호출 없음, itemSource:Set(새값)만 → itemState 구독한 리프만 갱신 +key가 유지, 순서만 변경 → renderFn 재호출 없음, Slot 위치만 조정(파괴/재생성 없음) +``` -**열린 질문**: Slot의 확장 옵션으로 넣을지, 별도 최상위 프리미티브(가칭 -`Keyed`/`ForEach`/`List`)로 분리할지 — 설계 자체가 전혀 없는 상태. quad의 -"명시적 의존성" 철학(`:With`)과는 직교하는 문제라 `:With`/`:Compute` 확장이 -아니라 Slot 쪽 확장이 자연스러워 보인다는 게 조사 에이전트 소견이지만 -확정 아님. +`itemState`는 이 프리미티브 내부 소유의 Source이고, `renderFn`엔 그 State +뷰만 노출한다(Source 자체를 주면 renderFn이 실수로 `:Set()`해서 diff +엔진과 경쟁할 수 있음). -## 2. Effect/Watch(자동 cleanup 포함) — 진짜 빈 자리, 중간 심각도 +### 폼 팩터 — 자유 함수로 정정 (State 메소드 프레이밍 철회) -React `useEffect`/Vue `watchEffect`/Solid `createEffect`는 "이펙트 -재실행 전/dispose 시 이전 cleanup을 프레임워크가 자동으로 불러준다"는 -계약을 가짐. quad `state:Observer(fn)`는 무효화 신호만 주고 cleanup 자동 -관리가 없음 — `fn`이 매번 뭔가를 새로 구독/생성한다면 이전 것을 정리하는 -책임이 전적으로 사용자 코드에 있음. +이전 라운드에서 "독립 프리미티브 vs 원천 종속 파생 데이터" 원칙을 적용해 +`state:Keyed(...)`처럼 **State의 메소드**로 두자고 제안했는데, 사용자가 +정확한 반례를 지적함: **Source를 안 쓰는 컴포넌트는 이 메소드 자체에 +접근을 못 한다.** 정적 데이터(한 번만 렌더되고 다시는 안 바뀌는 리스트)를 +키 기반으로 렌더링하고 싶을 뿐인데 굳이 `Source(정적데이터)`로 감싸야 +한다면 불필요한 강제다. -`process`/`retract` 쌍이 정확히 이 문제(이전 처리를 무르고 새로 처리)를 -풀지만, 이건 base가 소유한 KV 핸들러 계층에 갇힌 내부 메커니즘이지 사용자가 -임의의 부수효과(`RunService.Heartbeat` 구독, 폴링 타이머 등)에 쓸 수 있는 -공개 API가 아님. 이미 증명된 내부 패턴을 사용자 레벨 `Effect(fn)`(반환값을 -다음 실행 전 cleanup으로 호출)로 얇게 노출하는 정도로, 큰 설계 변경 없이 -채울 수 있는 자리로 보임. +재검토 결과 — quad는 이미 **leaf 프로퍼티가 "리터럴 값 또는 State" 둘 다 +받는 폴리모픽 컨벤션**을 갖고 있다(`BackgroundColor3 = someColor`도 +`BackgroundColor3 = someState`도 같은 자리에서 됨, 정적이면 한 번만 세팅, +State면 구독). 이 프리미티브도 같은 컨벤션을 따르는 게 자연스럽다 — +**자유 함수**로 두고 `data` 인자가 plain array/table이든 `State`/ +`Source`든 둘 다 받게 한다. Plain이면 diff 로직 자체가 발동 안 하고 +(다시는 안 바뀌므로 최초 1회 배치만 하면 끝), State/Source면 위 메커니즘이 +동작한다. 이름은 아직 미정이지만 시그니처 형태: -*참고*: Fusion `Cleanup`/`doCleanup`, Vide `cleanup()`은 "명시적 dispose -콜백 등록 리스트" 모델이라 quad의 GC-native 철학과 정면 충돌하고 이미 -`base/comparison-fusion-vide.md`에서 반면교사로 다뤄짐 — 여기서 제안하는 -건 그것과 달리 *cleanup 콜백을 자동으로 호출해주는 것*(dispose 리스트 -직접 관리가 아님)이라 같은 문제가 아님, 혼동하지 말 것. +``` +<이름>(data: {[K]: V} | State<{[K]: V}>, keyFn: (V, K) -> Key, renderFn: (Key, State) -> Child) -> Slot +``` -## 3. Batch/Transaction — 부분적 빈 자리, 이미 문서화된 문제와 직결 +### Slot 확장 — `Extract`, 그리고 확정해야 할 소유권 모델 -quad의 push-invalidate/pull-recompute 모델은 batching을 상당 부분 공짜로 -줌 — `Get()`이 호출되기 전까진 `Set()`을 연달아 해도 재계산이 안 일어남. -다만 이미 문서에 자기진단된 예외가 있음: **store-bind 이벤트 핸들러는 -무효화 신호를 받는 즉시 pull**(`bind-system-plan.md` "자주 재계산되는 -State에 이벤트를 직접 물리면... churn 비용"). 즉 여러 Source가 `:With`로 -물린 파생 State에 store-bind 핸들러가 붙어 있으면, 여러 `Set()`을 순서대로 -실행하는 도중 중간 상태마다 핸들러가 여러 번 재실행되는 게 이미 확인된 -시나리오. `Batch(function() ... end)`류 opt-in 유틸은 이론적 완결성이 -아니라 **이미 확인된 실제 버그 클래스를 막는 것**이라 가치 있음 — 다만 새 -프리미티브라기보다 dispatch 엔진에 얹는 얇은 유틸 수준. +순서 변경(파괴 없이 위치만 옮기기)을 처리하려면 Slot에 **"파괴하지 않고 +빼내기"** 연산이 필요하다. 기존 확정 사항(`slot-plan.md`): -Vide `batch.luau:4-21`가 정확히 이 역할(여러 `source:set()`을 하나의 -flush로 묶음). +> retract되는 slot은 옮겨지지 않고 그냥 폐기된다 ... React의 portal류로 +> 나중에 옮길 수 있게 하는 것도 검토됐으나 이번 마일스톤에서는 +> 오버엔지니어링으로 판단, 하지 않음. -## 4. Context(트리 하위 암묵 전파) — 난이도 판정 완료, 기각 권고 (2026-08-06) +이건 **"다른 Slot으로 옮기기"(portal)를 안 한다**는 결정이지 **"같은 Slot +안에서 위치만 바꾸기"**를 막는 결정이 아니다 — 순서 재조정은 후자만 +필요하므로 이 결정과 충돌하지 않는다. -Fusion `Utility/Contextual.luau:28-88`(코루틴 키 weak table push-pop), Vide -`context.luau:14-72`(scope 그래프 조회)가 대응 개념. 서브에이전트에게 구현 -난이도 평가를 위임한 결과(상세 근거는 아래 "진행 중 논의" 절의 Context -서브섹션 참고), **동기적 저작 트리 안에서만 작동하는 얕은 버전은 구현 -난이도가 낮지만(Fusion 코드를 사실상 그대로 이식 가능), quad가 이미 -정상 패턴으로 확정한 "Slot에 이벤트 핸들러/코루틴에서 비동기로 자식이 -추가되는 경우"엔 조용히 기본값으로 폴백하는 함정이 생김**을 확인 — -Roblox Luau에 thread-local/async-context-propagation 훅이 없어 완전 -자동화는 사실상 불가능(플랫폼 한계, 구현 노력의 문제가 아님). Node -`AsyncLocalStorage`/Python `contextvars`가 같은 문제를 "자동"이 아니라 -"async 경계마다 명시적 캡처+재진입"으로 푸는 것과 동일한 결론. +**사용자가 명확히 한 최종 소유권 모델(확정)**: +- **Slot 자체의 바인딩은 귀속·불가역** — 한 번 마운트되면 그 Slot 컨테이너 + 자체를 다른 곳에 다시 바인드할 수 없음(기존 "재마운트 시 throw"와 동일). +- **Slot 안에 있는 개별 요소의 입출력은 자유** — 넣고 빼고 다시 넣는 것에 + 제약 없음. +- **단, 요소의 `.Parent`를 Slot API를 거치지 않고 직접 만지는 건 UB.** -**기각 권고 근거**: 얕은 버전조차 (a) quad가 스스로 정상 패턴으로 확정한 -Slot 비동기 추가에서 가장 먼저 깨지고, (b) `research/debug-tooling-plan.md`의 +제안: + +``` +Slot:Extract(key) -> element -- 파괴 없이 빼냄 +Slot:Add(element, index?) -- 기존 그대로, Extract로 뺀 것도 다시 넣을 수 있음 +``` + +리오더는 `Extract` + `Add(index)` 조합으로 구현(별도 `Move`/`Swap` 원시 +연산은 새로 안 만듦 — 원시 개수를 최소화). 덤으로 이게 +`pre-implementation-audit.md` 1-8번("isMounted가 element 단위와 Slot +컨테이너 단위를 뭉뚱그려 서술됨")을 자연스럽게 푼다 — `Extract`가 있으면 +"element의 mounted 여부는 가변, Slot 컨테이너 자체의 mounted 여부는 +불변"으로 두 개념이 명확히 분리된다. + +### 이름 후보 (확정 아님, 용어 정리 라운드 대상) + +`Keyed`는 탈락 — 타이핑이 어색하고 "Slot을 렌더한다"는 느낌과도 안 맞는다는 +사용자 피드백. 후보: +- `Render(data, keyFn, renderFn) -> Slot` — 가장 직접적("슬롯을 렌더한다"). + 단, quad는 "렌더 주기가 없다"를 아키텍처 원칙으로 강조해왔는데 이 이름만 + "Render"를 쓰면 혼동 가능성 있음. +- `Draw(data, keyFn, renderFn) -> Slot` — 짧음, "Render"와의 충돌은 피하지만 + 즉시모드 GUI(Dear ImGui류) 뉘앙스를 가져올 수 있어 quad의 유지형(retained) + 모델과 어긋나 보일 수 있음. +- `List(data, keyFn, renderFn) -> Slot` — 중립적, 결과가 "리스트"라는 것만 + 전달, 메커니즘을 과다 암시 안 함. + +세 후보 다 트레이드오프가 있어 확정 안 함 — `.claude/question.md`의 용어 +정리 라운드에 후보로 올려둠. + +### 남은 열린 질문 + +- 최종 이름(위 후보 중 또는 새 후보). +- `Extract`/`Add(index)` 조합의 정확한 시그니처(에러 조건: 이미 다른 Slot에 + 있는 요소를 Add하면? 존재 안 하는 key를 Extract하면?). +- Fusion의 `ForPairs`/`ForKeys`/`ForValues` 3종 분리를 안 따르고 + `renderFn(key, itemState)` 1종으로 통합하는 안이 여전히 유력(단순화 + 후보, `pre-implementation-audit.md`의 "단순화 후보" 렌즈와 같은 결) — + 최종 확정 아님. +- **목표: M6(Slot) 착수 전, 가급적 M0 스파이크 이전에 이 전체를 완전히 + 정의**(사용자 명시적 요청) — 이 프리미티브가 Slot 자체의 CRUD 시맨틱 + (`pre-implementation-audit.md` 1-7, 지금 미정)과 얽혀 있어서, Slot을 + 먼저 정하고 나중에 여기를 끼워맞추면 재작업이 날 가능성이 높음. Slot + CRUD 시맨틱을 정의할 때 이 프리미티브의 요구사항을 같이 고려할 것. + +## 2. Effect — 거의 수렴, 시그니처만 남음 + +### Observer에 cleanup 반환 계약을 추가하는 안 — 기각 + +React `useEffect`류처럼 "`fn`이 `nil | () -> ()`를 반환하면 다음 재실행 +직전에 그걸 불러준다"를 `state:Observer(fn)`에 얹는 안을 검토했으나 +**사용자가 기각** — 클로저 업밸류로 이미 쉽게 되고 잘 작동하는데 +(`local lastConn; state:Observer(function() if lastConn then +lastConn:Disconnect() end; lastConn = ... end)`), 프레임워크가 이걸 +대신해줄 이유가 약하다는 판단. 이건 `pre-implementation-audit.md` +3-1번("`:Compute(fn)`의 `previous` 인자 — 클로저 업밸류로 이미 되는 걸 +별도 API로 만든 것일 수 있음")과 **정확히 같은 논리** — 일관성 있는 +판단으로 보임(3-1 자체도 같은 이유로 재검토 대상일 수 있음, 별도 항목). + +### Effect — 별도의 단순한 primitive로, "leaf 죽음에 확정 정리"만 담당 + +Roblox엔 `task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이 흔하고, +Luau 테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는 순간"을 +아는 유일한 방법은 `Instance.Destroying`류 명시적 신호뿐이다. 이런 +케이스(타이머 시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive는 +필요하다는 데 합의: + +``` +Effect(fn) -> EffectHandle -- fn을 즉시 1회 실행, 리턴값(nil | () -> ())은 + -- 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출 +``` + +Observer와 달리 **재실행 개념이 없다** — 값 변화에 반응해 다시 도는 건 +Observer(+클로저로 직접 짠 cleanup)의 영역이고, Effect는 순수하게 "설치 + +확정 정리" 페어 하나만 담당한다. children 배열에 leaf로 놓는 기존 Observer +바인딩 패턴을 그대로 재사용(그 leaf가 살아있는 동안만 유효, leaf가 죽으면 +정리 콜백 호출). 비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak +table로 되는 Observer보다 비쌈) — 사용자가 필요할 때만 쓰는 걸로 충분. + +**상태**: 사실상 수렴 — 시그니처(`fn`의 인자 유무, `EffectHandle`의 모양)만 +다듬으면 `base/`로 승격 가능해 보임. 계속 논의 원하면 이어감. + +## 3. Batch/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가 중간값을 한 번 거쳐가고, 두 번 대입됨 +``` + +### 왜 lexical block 방식(Solid `batch()`/MobX `runInAction()`)을 안 쓰는가 + +`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 모델 +자체가 협조적 스케줄링 환경과 구조적으로 안 맞는 것**으로 판단. + +### 결정: 프리미티브 없음, 대신 문서화 두 갈래 + +1. **심화(사용자 대상) 최적화 팁**: "여러 Source를 한꺼번에 바꿀 때 + store-bind가 걸린 파생값의 재계산/재대입이 중복되지 않도록 파이프라인 + 구성과 업데이트 순서에 유의하라"를 실용 가이드로 문서화. 예: 정말 + 여러 값이 자주 같이 바뀐다면 애초에 하나의 State로 묶어서 `:Set()`을 + 한 번만 하는 설계를 권장. +2. **quadnomicon(프레임워크 설계자 대상) 에세이**: "왜 Batch/Transaction이 + 없는가" — 위 코루틴 yield 위험 분석을 근거로, Solid/MobX식 lexical + transaction이 협조적 스케줄링(코루틴) 환경에서 왜 근본적으로 위험한지 + 설명. `documentation-content-map.md`에 후보로 등록함(아래 "문서화 + 백로그" 참고). + +## 4. Context — 기각 확정, 레이어드 Store 대안도 철회 (결정 완료) + +### 난이도 판정 요약 + +서브에이전트 조사 결과: 동기적 저작 트리에 한정한 얕은 버전(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**(자식 Source 모음이 자기한테 없는 키는 -부모로 `__index` 폴백 — Modifier/Source가 이미 쓰는 델리게이션 패턴과 동일 -계열)가 Context의 핵심 가치(서브트리별 오버라이드)를 명시적 전달 철학과 -비동기 안전성을 유지하면서 대부분 재현함 — 오버라이드 Store 참조는 여전히 -prop으로 명시 전달해야 하지만, 이건 `component-composition-plan.md`가 이미 -확정한 비용이라 새로 감수하는 게 아님. Roblox `require()` 캐싱 싱글톤(단일 -전역, 서브트리 오버라이드 불가)은 오버라이드가 필요 없는 단순 케이스에는 -여전히 충분. +채널을 만듦. + +### 레이어드 Store 대안도 철회 (사용자 반박 수용) + +이전 라운드에서 대안으로 "레이어드 Store"(자식 Source 모음이 없는 키는 +부모로 `__index` 폴백)를 권고했는데, 사용자 반박으로 철회함: + +- quad는 이미 **타입으로 강제되는 명시적 Store 전달**을 갖고 있고, 이건 + Context의 "Provider 안 넣으면 조용히 기본값/에러"보다 **더 안전**하다 + (컴파일타임에 걸림 vs 런타임에 조용히 새는 값) — Context보다 나은 지점. +- "필드 일부만 오버라이드, 나머지는 부모 값 그대로"가 필요하면, 오버라이드 + 지점에서 `Store({...부모값, 변경필드=새값})`을 **한 번 명시적으로** + 만들어서 그 지점부터 평소처럼 prop으로 넘기면 끝 — Modifier가 이미 쓰는 + "merge, 나중 게 이김" 패턴 재사용 가능, 새 primitive 불필요. 레이어드 + Store(읽는 시점에 몇 단계를 거슬러 올라가는지 안 보이는 자동 폴백)는 + 정확히 Context와 같은 이유(디버깅 어려움 — "이 값이 왜 이거지?"를 + 추적하려면 부모 체인을 다 훑어야 함)로 얻는 것보다 잃는 게 크다. +- 서드파티 라이브러리가 뭔가 필요하면, 타입이 강제하는 명시적 요구 + (`props.Theme: Store`)가 "몰래 안 줘서 죽는다"보다 나은 실패 + 모드 — Slot 기반 저작 모델(부모가 자손을 직접 구성)에서도 이런 요청은 + 자연스럽게 props로 흐름. + +### 결론 + +Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Context가 +없는가"(명시적 Store 전달이 이미 그 역할을 하고, 타입 강제가 Context의 +실패 모드보다 안전하다는 논증)도 quadnomicon 에세이 후보로 등록(아래 +"문서화 백로그" 참고). ## 빈 자리 아닌 것으로 확인된 것들 @@ -174,112 +312,24 @@ prop으로 명시 전달해야 하지만, 이건 `component-composition-plan.md` - **디바운스/스로틀**: Fusion/Vide/v1 어디에도 공개 프리미티브로 없음 — 세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황. -## 제안 우선순위 (결정 아님, 검토 순서 제안) +## 문서화 백로그 (2026-08-06, `documentation-content-map.md`에도 반영) -1. **키 기반 컬렉션 재조정** — 실전 영향이 가장 크고, 설계가 완전히 - 빈 상태라 가장 먼저 사용자와 상의할 가치. -2. **Effect 공개 API** — `process`/`retract`가 이미 증명한 패턴을 얇게 - 노출하는 정도라 구현 비용 낮음. -3. **Batch** — 이미 문서화된 churn 문제의 직접 해법, opt-in 유틸 수준. -4. **Context** — 기각 권고(2026-08-06 난이도 판정 완료). 서브트리 오버라이드가 - 실제로 필요해지면 Context가 아니라 **레이어드 Store**를 검토할 것. +- **quadnomicon 에세이**: "왜 Batch/Transaction이 없는가"(코루틴 yield + 위험), "왜 Context가 없는가"(명시적 타입 강제 Store 전달이 이미 그 + 역할을 함). +- **심화 문서**: "여러 Source를 한꺼번에 바꿀 때 중복 재계산/재대입을 + 피하는 파이프라인/업데이트 순서 최적화 팁"(Batch 없이 사용자가 직접 + 할 수 있는 것). -## 진행 중 논의 (2026-08-06 후속 세션) +## 제안 우선순위 -메인 브랜치에 병합된 `pre-implementation-audit.md`의 1-7번("Slot의 -`add`/`remove`/`clear` CRUD 의미론이 정의돼 있지 않음")과 정확히 맞물리는 -타이밍이라, 키 기반 컬렉션 재조정을 Slot 설계에 바로 접목해보는 논의 시작. - -### 키 기반 컬렉션 재조정 — 설계 스케치 (확정 아님) - -사용자가 처음부터 "Slot의 상위 요소"로 만들고 싶다고 명시 — 두 가지 프레이밍을 -제시함: (a) Slot을 만들어주는 팩토리 함수, (b) State의 "터미널 연산"(스트림의 -`.collect()`류)으로 Slot을 얻는 것(`state:?() -> Slot`). - -**두 프레이밍 중 (b)가 quad의 기존 원칙과 더 잘 맞음** — 2026-08-06 세션에서 -이미 확정된 "독립 프리미티브 vs 원천 종속 파생 데이터" 원칙(`state:Observer(fn)`가 -메소드고 자유 함수가 아닌 이유와 같은 논리, `store-semantics.md`)을 그대로 -적용하면: 키 기반 Slot은 그 뒤에 있는 State 없이는 존재 의미가 없는 **파생 -데이터**이므로, 자유 함수 팩토리(`Type(args)` 패턴)가 아니라 **State의 -메소드**로 두는 게 일관적. 가칭 `state:Keyed(keyFn, renderFn) -> Slot`. - -**메커니즘 스케치**(전부 이미 확정된 조각들의 재조합 — 새 마운트/디스패치 -장치 불필요): -- 내부적으로 `state:Observer(fn)`과 동형 — `fn`이 무효화 신호를 받을 때마다 - `Get()`으로 새 테이블을 pull하고, 이전에 본 key 집합과 diff. -- 새 key → `renderFn(key, itemState)` 호출 후 `Slot:add(...)`. -- 사라진 key → `Slot:remove(...)`(기존 확정 시맨틱대로 `retract`=폐기, 옮기지 - 않음 — `slot-plan.md`와 자동으로 일관됨). -- 유지되는 key → Slot 조작 없이 그 항목의 `itemState`에만 새 값을 반영(재생성 - 안 함) — Fusion `SubObject`/Vide `values()`가 하는 "값만 갱신"과 동일 효과를, - `renderFn`이 `itemState: State`를 받는 것만으로 자연스럽게 얻음(항목 값이 - 바뀌어도 renderFn 자체는 재호출 안 되고, `itemState`를 구독한 리프만 - 갱신됨). -- 소유권: 반환된 Slot은 기존 "엄격한 단일 마운트" 규칙 그대로 적용 — 새 규칙 - 불필요. -- 패키지 경계: diff 알고리즘(순수 데이터 로직)은 `quad-base`, 실제 Instance - 생성/제거는 기존 Slot 핸들러 경로 그대로 재사용 — `slot-plan.md`가 이미 - 확정한 base/roblox 분리와 동일 패턴. - -**열린 세부**: Fusion은 `ForPairs`/`ForKeys`/`ForValues` 3종으로 나뉘는데, -quad는 `renderFn(key, itemState)`가 항상 key+itemState를 다 주고 안 쓰는 -쪽은 그냥 무시하게 하는 **1종 통합안**이 단순화 후보로 보임(3개로 쪼갤 -근거가 약해 보임 — `pre-implementation-audit.md`의 "단순화 후보" 렌즈와 -같은 결). `keyFn` 생략 시 입력이 이미 맵이면 맵 key를 그대로 identity로 -쓰는 것도 자연스러운 기본값 후보. 이름(`Keyed`/`Each`/`List`/`ToSlot`)은 -용어 정리 라운드로 이월. - -**다음 단계**: 사용자 피드백 반영해 스케치 다듬고, 이견 없으면 M6(Slot) -착수 시점에 `slot-plan.md`/`bind-system-plan.md`에 정식 반영. - -### Context — 구현 난이도 판정 완료 (2026-08-06) - -사용자 확인: Context는 React `useContext`(Provider가 위에서 값을 심고, -하위 어디서든 prop 없이 읽는 패턴)와 같은 개념 맞음. 구현 난이도가 채택 -여부를 사실상 결정한다는 사용자 판단에 따라 서브에이전트에게 난이도 평가를 -위임했고, 결과 수렴 완료 — **위 "4. Context" 절이 최종 결론**. 요지만 -재정리: - -- **난이도 등급**: 동기적 저작 트리에 한정한 얕은 버전 = **낮음**(Fusion - `Contextual`의 "코루틴을 키로 하는 weak table push-pop"을 그대로 이식 - 가능, quad-base 단독으로 완결, quad-roblox 분리조차 불필요). 완전 - 자동(비동기 Slot 추가까지 자동 전파) = **매우 높음, 사실상 불가** — - Roblox Luau에 thread-local/async-context-propagation 훅이 없어서 quad가 - 소유하지 않는 임의의 콜백 경계(`Signal:Connect`, `task.spawn`, Promise)를 - 가로챌 방법이 없음. 이건 quad 설계의 문제가 아니라 플랫폼 자체의 한계. -- **사용자의 직관이 정확히 맞았음**: 동기 콜스택 부분은 싸지만, Slot에 - 나중에(이벤트 핸들러/코루틴에서) 비동기로 자식이 추가되는 케이스에서 - Fusion 방식은 **에러 없이 조용히 `defaultValue`로 폴백**함 — "테마가 - 가끔 기본값으로 보인다"는 형태의 원인 추적 어려운 버그를 만듦(Vide - 방식은 폴백 대신 에러를 던지지만, 애초에 이 시나리오를 지원 대상으로 - 설계한 적이 없어 참고할 답이 없기는 마찬가지). Node - `AsyncLocalStorage`/Python `contextvars`도 "자동"이 아니라 "async 경계마다 - 명시적으로 값을 캡처해서 재진입"하는 방식으로 같은 문제를 풀고 있어, - 이식해도 결국 "prop drilling 비용"이 "매 async 경계 캡처+재진입 보일러 - 플레이트 비용"으로 자리만 옮김. -- **기각 이유(효용 대비 비용)**: 얕은 버전조차 채택할 가치가 낮음 — (1) - `base/slot-plan.md`가 정상 패턴으로 명시한 Slot 비동기 추가에서 가장 - 먼저, 가장 조용히 깨짐, (2) `research/debug-tooling-plan.md`의 "모든 - 연결은 선언된 그래프여야 한다"는 quad-debug 철학과 충돌하는 안 보이는 - 채널을 만듦. -- **대안 비교 결과**: Store 병합(난이도 매우 낮음, 그러나 drilling 자체는 - 안 없앰) < 타입 안전 서브셋 전달(난이도 낮음, 하지만 트리 전파 문제와는 - 별개 축이라 해결책이 아님) < **레이어드 Store**(난이도 낮음~중간, Context의 - 핵심 가치인 "서브트리별 오버라이드"를 대부분 재현하면서 명시적 전달 - 철학·비동기 안전성 둘 다 유지 — Modifier의 제네릭 `__index` 트릭, - Source가 State를 만족시키는 `__index` 델리게이션과 같은 이미 검증된 - 패턴 계열이라 구현 리스크도 낮음) — **레이어드 Store가 최종 권고안.** - 사용자가 스스로 "어려울 것 같다"고 짐작했던 것과 달리, quad2-try에서 - 금지한 `Class:Extend()`류 행위(메소드) 상속과는 다른 층위(순수 데이터 - 값 조회의 `__index` 폴백일 뿐)라 실제로는 낮은 난이도로 판정됨 — 다만 - Luau 타입 레벨에서 "자식 키 ∪ 부모의 나머지 키"를 구조적으로 표현하는 - 부분은 M0가 이미 검증 대상으로 잡은 `Source satisfies State`류 - 솔버 위험과 비슷한 급이라, 도입한다면 같은 스파이크에 끼워 검증하는 게 - 합리적. -- **결론**: Context라는 이름의 범용 프리미티브는 채택하지 않음. 테마/로케일 - 서브트리 오버라이드가 실제로 필요해지는 시점에 레이어드 Store를 별도 - 후보로 검토(지금 착수 우선순위 낮음, 키 기반 컬렉션 재조정이 여전히 - 더 시급). +1. **키 기반 컬렉션 재조정** — 여전히 최우선, 설계 진행 중. M0 이전 완전 + 확정이 목표. +2. **Effect** — 거의 수렴, 시그니처만 다듬으면 됨. +3. **Batch** — 결정 완료(프리미티브 없음, 문서화로 대체), 더 이상 검토 + 대상 아님. +4. **Context** — 결정 완료(기각, 레이어드 Store도 철회), 더 이상 검토 + 대상 아님. ## 참고: 조사에 사용한 소스 근거 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index e2975d7..5a9319f 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -122,6 +122,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 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. 여러 Source를 한꺼번에 바꿀 때 중복 재계산/재대입을 피하는 파이프라인/업데이트 순서 최적화 팁(Batch 프리미티브 없이 사용자가 직접 할 수 있는 것) — `research/additional-primitives-plan.md` "문서화 백로그" 절(2026-08-06 신설) (13번이었던 "Fusion/Vide 경험자용 비교 섹션"은 2026-08-06 재분류로 아래 6번 `quadnomicon`으로 이동) @@ -141,6 +142,20 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 2. `:With`+`:Compute` 명시적 파생값이 Vide의 암묵적 ambient stack 대신 채택된 이유 — Vide 경험자 대상 비교 +**2026-08-06 후속 세션에서 추가된 후보(둘 다 `research/ +additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: +3. 왜 Batch/Transaction이 없는가 — Solid `batch()`/MobX `runInAction()`류 + lexical transaction이 Roblox의 협조적 스케줄링(코루틴 yield) 환경에서 + 왜 근본적으로 위험한지(전역/코루틴 스코프 플래그가 새 코루틴 스폰·영구 + yield에 어떻게 깨지는지 구체 시나리오 포함) — Fusion/Vide 비교는 아니고 + "설계 원리"형 에세이라 Rustonomicon 패러디 취지(비슷한 프레임워크 + 설계자용)와 잘 맞음 +4. 왜 Context가 없는가 — 얕은 버전(코루틴 키 weak table push-pop)조차 + quad가 정상 패턴으로 확정한 Slot 비동기 추가에서 조용히 깨지는 이유, + 완전 자동 버전이 Roblox Luau의 플랫폼 한계(thread-local 없음)로 불가한 + 이유, 명시적 타입 강제 Store 전달이 Context보다 안전한 이유(레이어드 + Store 대안도 왜 함께 기각됐는지 포함) + **publish 안 하는 것과의 경계**: 세션별 정정 이력, 조사 원자료(Fusion 반응 그래프 BFS 분석, quad2-try 죽은 코드 조사 등)는 quadnomicon에도 안 들어감 — 그건 새 티어가 필요한 게 아니라 애초에 `.claude/` 내부 @@ -161,6 +176,9 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 - UI 숏핸드 `RoundSize` 드롭 여부 (`research/ui-shorthand-plan.md`) - `Attribute` 제네릭 vs 타입별 정적 생성자 (`bind-system-plan.md`) - provider/processor 네이밍 (`module-lifecycle-plan.md`) +- 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 + 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ + additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) 이 항목들은 `.claude/question.md`에도 이미 열린 질문으로 잡혀있음 — 여기선 "확정 전엔 문서화 대상 아님"이라는 표시만 겸함. From ccde1cb31cfe0a10717e3f89e4635bfae49c1536 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 00:05:18 +0900 Subject: [PATCH 24/62] =?UTF-8?q?research:=20Blocker=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=AF=B8=ED=8B=B0=EB=B8=8C=20=EC=B1=84=ED=83=9D=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20Batch(lexical)=20=EB=8C=80=EC=95=88,=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=A7=88=EB=AC=B4=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션 마무리 라운드. Batch를 부활시킨 게 아니라 완전히 별개인 새 primitive Blocker를 채택한 것으로 명확히 분리: - Batch(함수/코루틴 스코프 lexical block)는 그대로 기각 유지, 반면교사 기록. - Blocker: 콜스택/코루틴이 아니라 값(On/Off)으로 지연 구간을 표현해 코루틴 yield 위험을 구조적으로 우회. state:Block(blocker)->state가 호출 즉시 onunblock 핸들을 등록(지연 등록 아님, 사용자 정정 반영). 이름 확정 (Blocker/On/Off/IsBlocked/HasBlockedEmit). 재진입(네스팅)은 의도적으로 미지원 — Rust poisoned-mutex류 위험 회피, 문서화 강조 필수로 기록. 사용 가이드: 파이프라인 최종 연산 지점에 배치. documentation-content-map.md에 새 심화/quadnomicon 콘텐츠 후보 반영: State 파생 체인 동작 원리, :Compute의 조건부 의존값 사용 팁, Blocker 사용 가이드(네스팅 금지 최우선 강조), "왜 Batch 대신 Blocker인가" 비교 에세이, push-invalidate/pull-recompute의 laziness 설계 철학 심층 에세이. Co-Authored-By: Claude Sonnet 5 --- .claude/question.md | 25 ++- .../research/additional-primitives-plan.md | 171 +++++++++++++++--- .claude/research/documentation-content-map.md | 41 ++++- 3 files changed, 192 insertions(+), 45 deletions(-) diff --git a/.claude/question.md b/.claude/question.md index 42ed0fb..2acd39e 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -10,16 +10,16 @@ ## 지금 열려있는 것 (우선순위순) -### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06) +### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06~07) 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 여러 서브에이전트 조사 + 사용자와 라이브 논의로 계속 수렴 중, 상세는 `research/ additional-primitives-plan.md`. 요지: -- **키 기반 동적 컬렉션 재조정(유일하게 아직 열려있음, 최우선)**: Fusion - `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, React - `key` prop에 대응하는 프리미티브가 quad엔 전혀 없음 확인 — +- **키 기반 동적 컬렉션 재조정(유일하게 아직 완전히 열려있음, 최우선)**: + Fusion `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, + React `key` prop에 대응하는 프리미티브가 quad엔 전혀 없음 확인 — `pre-implementation-audit.md` 1-7번(Slot CRUD 미정의)과 같은 지점이라 같이 정의해야 함. **자유 함수**(plain data 또는 State 둘 다 받는 폴리모픽 시그니처, State 메소드 프레이밍은 Source 안 쓰는 컴포넌트가 @@ -30,10 +30,21 @@ additional-primitives-plan.md`. 요지: 단순 primitive(재실행 개념 없음, Observer와 별개)로 합의, 시그니처만 남음. Observer에 cleanup 반환 계약을 얹는 안은 기각(클로저 업밸류로 이미 충분 — `pre-implementation-audit.md` 3-1과 같은 논리). -- **Batch/Transaction — 프리미티브로 안 만들기로 결정 완료**: lexical +- **Blocker — 채택, 핵심 메커니즘+이름 확정(2026-08-07 신설)**: 여러 + Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 + primitive — lexical Batch(아래 항목, 기각)와 달리 콜스택/코루틴이 아니라 + **값**(`Blocker` 객체의 `On()`/`Off()`)으로 지연 구간을 표현해 코루틴 + yield 위험을 구조적으로 우회함. `state:Block(blocker) -> state`가 + gated state를 반환(호출 즉시 onunblock 핸들 등록), `IsBlocked`/ + `HasBlockedEmit` 필드, 재진입(네스팅)은 의도적으로 미지원(Rust + poisoned-mutex류 위험 회피 — 겹치는 배치는 각자 새 `Blocker`를 쓸 것, + **문서화에서 강하게 명시 필요**). `base/`로 승격 가능한 수준, 남은 건 + 문서화뿐. +- **Batch(함수/코루틴 스코프 lexical block) — 기각 확정**: lexical transaction 블록이 코루틴 yield 위에서 구조적으로 위험(전역/코루틴 - 스코프 플래그 둘 다 새 코루틴 스폰이나 영구 yield에 깨짐) — 대신 - 심화 최적화 팁 + quadnomicon "왜 없는가" 에세이로 문서화. + 스코프 플래그 둘 다 새 코루틴 스폰이나 영구 yield에 깨짐) — Blocker로 + 대체됐으므로 더 이상 미해결 문제 아님, quadnomicon "왜 Batch 대신 + Blocker인가" 에세이 대상. - **Context — 기각 확정, 대안이던 레이어드 Store도 철회**: 완전 자동 버전은 Roblox Luau 플랫폼 한계로 사실상 불가, 얕은 버전도 Slot 비동기 추가에서 조용히 깨짐 + quad-debug 철학과 충돌. 레이어드 Store 대안은 diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 2d5be45..636b420 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -1,9 +1,10 @@ # 추가 프리미티브 필요성 조사 — 다른 프레임워크 대비 갭 분석 -**상태**: research — 사용자와 라이브 논의로 계속 수렴 중(2026-08-06). Context/ -Batch는 **결정 완료**(둘 다 프리미티브로 안 만듦). 키 기반 컬렉션 재조정은 -설계 진행 중(사용자가 "작업 전에 모든 정의를 마치고 싶다"고 명시 — M0 전 -완전 확정이 목표). Effect는 거의 수렴, 시그니처만 남음. +**상태**: research — 사용자와 라이브 논의로 수렴(2026-08-06~07). Context/ +Batch(lexical)는 **기각 확정**. **Blocker**(새 primitive, Batch의 대안으로 +채택)는 핵심 메커니즘+이름 확정, 문서화만 남음. 키 기반 동적 컬렉션 +재조정은 설계 진행 중(사용자가 "작업 전에 모든 정의를 마치고 싶다"고 +명시 — M0 전 완전 확정이 목표). Effect는 거의 수렴, 시그니처만 남음. ## 배경 @@ -28,7 +29,8 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 |---|---|---| | 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** | 설계 진행 중 | | Effect(leaf 죽음에 확정 정리) | 진짜 빈 자리 | 거의 수렴, 시그니처만 남음 | -| Batch/Transaction | **프리미티브로 안 만듦** — 문서화로 대체 | 결정 완료 | +| **Blocker(값 기반 emit 지연/합치기)** | **채택** — Batch의 대안 | 핵심 메커니즘+이름 확정, 문서화만 남음 | +| Batch(함수/코루틴 스코프 lexical block) | **기각** | 결정 완료 | | Context(트리 하위 암묵 전파) | **기각** — 대안(레이어드 Store)도 철회 | 결정 완료 | | Observer에 cleanup 반환 계약 추가 | **기각** — 클로저 업밸류로 이미 충분 | 결정 완료 | | Untrack/Peek | 빈 자리 아님 | - | @@ -192,7 +194,13 @@ table로 되는 Observer보다 비쌈) — 사용자가 필요할 때만 쓰는 **상태**: 사실상 수렴 — 시그니처(`fn`의 인자 유무, `EffectHandle`의 모양)만 다듬으면 `base/`로 승격 가능해 보임. 계속 논의 원하면 이어감. -## 3. Batch/Transaction — 프리미티브로 안 만듦 (결정 완료) +## 3. Batch(함수/코루틴 스코프 lexical block) — 기각 확정 + +**중요**: 아래는 "값 기반 지연/합치기"라는 문제 자체를 기각한 게 아니라, +**그 문제를 lexical block(Solid `batch()`/MobX `runInAction()`류)으로 +풀려는 접근만** 기각한 것이다. 실제 해법은 완전히 다른 별개 primitive인 +**Blocker**(아래 3-1번)로 채택됨 — 이 절은 "왜 lexical 접근은 안 되는가"만 +다루는 순수 반면교사 기록으로 남긴다. ### "즉시 pull"이 뭔지 (참고용 예시) @@ -209,7 +217,7 @@ b:Set(2) -- total 무효화 → 핸들러가 즉시 (av=1, bv=2)로 다시 재 -- BackgroundColor3가 중간값을 한 번 거쳐가고, 두 번 대입됨 ``` -### 왜 lexical block 방식(Solid `batch()`/MobX `runInAction()`)을 안 쓰는가 +### 왜 lexical block 방식을 안 쓰는가 `Batch(fn)`을 "플래그 세우고 fn 실행, 끝나면 flush"로 구현하면 **fn이 yield하는 순간 위험해진다**(사용자 지적, 정확함): @@ -224,20 +232,108 @@ yield하는 순간 위험해진다**(사용자 지적, 정확함): pull보다 더 나쁜 실패 모드. 이건 구현을 잘 짜서 피할 수 있는 버그가 아니라 **lexical block 모델 -자체가 협조적 스케줄링 환경과 구조적으로 안 맞는 것**으로 판단. +자체가 협조적 스케줄링 환경과 구조적으로 안 맞는 것**으로 판단, 기각. -### 결정: 프리미티브 없음, 대신 문서화 두 갈래 +## 3-1. Blocker — 채택된 새 primitive (Batch의 대안, 2026-08-06~07 세션) -1. **심화(사용자 대상) 최적화 팁**: "여러 Source를 한꺼번에 바꿀 때 - store-bind가 걸린 파생값의 재계산/재대입이 중복되지 않도록 파이프라인 - 구성과 업데이트 순서에 유의하라"를 실용 가이드로 문서화. 예: 정말 - 여러 값이 자주 같이 바뀐다면 애초에 하나의 State로 묶어서 `:Set()`을 - 한 번만 하는 설계를 권장. -2. **quadnomicon(프레임워크 설계자 대상) 에세이**: "왜 Batch/Transaction이 - 없는가" — 위 코루틴 yield 위험 분석을 근거로, Solid/MobX식 lexical - transaction이 협조적 스케줄링(코루틴) 환경에서 왜 근본적으로 위험한지 - 설명. `documentation-content-map.md`에 후보로 등록함(아래 "문서화 - 백로그" 참고). +### 핵심 아이디어 — 콜스택/코루틴이 아니라 값으로 지연 구간을 표현 + +Batch가 실패하는 근본 이유는 "지연 구간"을 콜스택/코루틴 스코프로 +표현하려 했기 때문이다. `Blocker`는 그 구간을 **그냥 사용자가 들고 있는 +값**으로 표현한다 — `On()`/`Off()`를 부르는 두 시점 사이에 얼마나 많은 +yield/코루틴 전환이 끼어도, 심지어 완전히 다른 코루틴에서 `Off()`를 +불러도 아무 문제가 없다. Batch를 무너뜨렸던 세 가지 실패 모드(전역 플래그 +오염, 새 코루틴 미상속, 영구 yield로 인한 무기한 방치)가 구조적으로 전부 +해당 안 됨. + +### 메커니즘 (확정) + +``` +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면 + 아무 것도 안 함(이미 언블록 상태에서 다시 Off를 불러도 안전 — + idempotent). + +**`:Get()`엔 영향 없음** — 블록은 emit **전파**(eager 소비자에게 "바뀌었다" +알리는 신호)만 지연시킨다. 블록 중이라도 누군가 명시적으로 `:Get()`하면 +그 순간의 실제 값을 정상적으로 계산해서 준다 — `store-semantics.md`의 +"Get()은 라이브 레퍼런스를 준다" 원칙과 일치. + +### 사용 예시 — `state1, state2 -> state3` 케이스의 정답 + +처음 문제 제기("state1, state2 -> state3로 갈 때 둘 다 업데이트하면 state3가 +두 번 계산됨, 한번에 할 방법이 없다")에 대한 답: **`state3`(결합된 결과) +하나에만 `:Block`을 걸면 된다** — `state1`/`state2` 각각에 걸 필요 없음: + +```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를 이 + 블로커에 배선한다)가 같은 단어로 다른 두 동작을 가리키게 됨 — 자체 + API 안에서 `register`→`State` 리네임 때 겪었던 "모호함은 풀었는데 + 충돌이 새로 생긴" 패턴이 반복될 뻔한 걸 미리 피함. +- 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태) — `Enabled`보다 + 명확(enabled는 "정상 작동 중"으로도 읽혀 헷갈릴 수 있음). +- 필드: **`HasBlockedEmit`**(gated state의 대기 플래그) — `Is`/`Has` + 접두어로 불리언임을 바로 알려줌. +- 메소드: `state:Block(blocker) -> state`. + +### 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 + +`IsBlocked`는 카운터가 아니라 단순 불리언이고, **의도적으로 그렇게 +둔다.** 레퍼런스 카운팅으로 네스팅을 지원할 수도 있었지만, 그러면 +"`On()` 여러 번, `Off()` 실수로 적게" 같은 버그가 **영구 블록으로 조용히 +새는** 더 위험한 실패 모드를 만든다. 언어 차원에서 "On~Off 사이 코드가 +죽었는지, 스레드가 죽었는지"를 추적하는 것도 해키해서 하지 않기로 함(Rust의 +"poisoned mutex" — 락 구간 안에서 패닉이 나면 락이 오염 상태가 되는 것과 +유사한 문제의식, 그 트래킹 자체를 만들지 않기로 함). + +**대신 확정된 규칙**: 겹치는 배치가 필요하면 **각자 새 `Blocker` 인스턴스를 +만들 것** — 하나의 Blocker를 여러 컨텍스트에서 재사용/중첩하지 않는다. +`Off()`는 스태킹 없이 즉시 그 자리에서 꺼진다. **이 제약은 반드시 사용자 +문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 +조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. + +### 상태: 핵심 메커니즘+이름 확정. 남은 건 문서화뿐 + +`Extract`/`Add`처럼 세부 시그니처가 더 필요한 다른 항목들과 달리, Blocker는 +설계 질문이 남아있지 않음 — `base/`로 승격 가능한 수준. quadnomicon에서 +"Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 게 좋은 소재(아래 +"문서화 백로그" 참고). ## 4. Context — 기각 확정, 레이어드 Store 대안도 철회 (결정 완료) @@ -312,24 +408,39 @@ Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Co - **디바운스/스로틀**: Fusion/Vide/v1 어디에도 공개 프리미티브로 없음 — 세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황. -## 문서화 백로그 (2026-08-06, `documentation-content-map.md`에도 반영) +## 문서화 백로그 (2026-08-06~07, `documentation-content-map.md`에도 반영) -- **quadnomicon 에세이**: "왜 Batch/Transaction이 없는가"(코루틴 yield - 위험), "왜 Context가 없는가"(명시적 타입 강제 Store 전달이 이미 그 - 역할을 함). -- **심화 문서**: "여러 Source를 한꺼번에 바꿀 때 중복 재계산/재대입을 - 피하는 파이프라인/업데이트 순서 최적화 팁"(Batch 없이 사용자가 직접 - 할 수 있는 것). +- **quadnomicon 에세이**: + - "왜 lexical Batch를 기각하고 대신 값 기반 Blocker를 택했는가" — 코루틴 + yield 위험 분석(Batch 절)과 Blocker의 설계(3-1절)를 나란히 비교. + - "왜 Context가 없는가"(명시적 타입 강제 Store 전달이 이미 그 역할을 함). + - "왜 push-invalidate/pull-recompute 설계가 laziness와 재계산 방지를 + 최우선 목표로 뒀는가" — Blocker 같은 파생 프리미티브가 이 목표 위에서 + 자연스럽게 나온 이유까지 포함해 기존 심화 콘텐츠 후보 3번(`왜 + push-invalidate/pull-recompute인가`)을 더 깊게 확장. +- **심화 문서**: + - "State 파생 체인 동작 원리" — emit이 아래로 전파되고, `Get()` 요청이 + 위로 거슬러 올라가 재계산된 뒤 다시 아래로 내려오는 흐름을 명확히 + 설명(Blocker/Effect 둘 다 이 흐름 위에서 동작하므로 선행 이해로 필요). + - "`:Compute` 함수 안에서 `if` 등으로 일부 의존값만 조건부로 사용하는 + 유연한 구조" — 명시적 의존성 선언(`:With`) 위에서도 실제 계산은 + 조건부로 일부만 쓸 수 있다는 팁. + - "Blocker 사용 가이드" — 파이프라인 최종 연산 지점에 배치, **네스팅 + 금지를 강하게 명시**(위 3-1절 "재진입" 참고, 문서화 시 최우선 강조 + 항목). + - "여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산을 피하는 + 파이프라인/업데이트 순서 팁"(Blocker를 안 쓰는 단순 케이스용 보조 팁, + 기존 "심화 최적화 팁" 항목을 Blocker 존재를 전제로 재조정). ## 제안 우선순위 1. **키 기반 컬렉션 재조정** — 여전히 최우선, 설계 진행 중. M0 이전 완전 확정이 목표. 2. **Effect** — 거의 수렴, 시그니처만 다듬으면 됨. -3. **Batch** — 결정 완료(프리미티브 없음, 문서화로 대체), 더 이상 검토 - 대상 아님. -4. **Context** — 결정 완료(기각, 레이어드 Store도 철회), 더 이상 검토 - 대상 아님. +3. **Blocker** — 핵심 메커니즘+이름 확정, `base/`로 승격 가능한 수준. + 문서화(특히 네스팅 금지 강조)만 남음. +4. **Batch(lexical)/Context** — 둘 다 결정 완료(기각), 더 이상 검토 대상 + 아님. ## 참고: 조사에 사용한 소스 근거 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 5a9319f..04a5836 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -122,7 +122,23 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 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. 여러 Source를 한꺼번에 바꿀 때 중복 재계산/재대입을 피하는 파이프라인/업데이트 순서 최적화 팁(Batch 프리미티브 없이 사용자가 직접 할 수 있는 것) — `research/additional-primitives-plan.md` "문서화 백로그" 절(2026-08-06 신설) +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`를 만들 것 — + 안 지키면 조용히 잘못된 시점에 조기 해제되는 원인 추적 어려운 버그로 + 이어짐) — `research/additional-primitives-plan.md` 3-1절 +19. 여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산/재대입을 + 피하는 파이프라인/업데이트 순서 팁(Blocker를 안 쓰는 단순 케이스용 + 보조 팁) — `research/additional-primitives-plan.md` "문서화 백로그" 절 (13번이었던 "Fusion/Vide 경험자용 비교 섹션"은 2026-08-06 재분류로 아래 6번 `quadnomicon`으로 이동) @@ -142,19 +158,28 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 2. `:With`+`:Compute` 명시적 파생값이 Vide의 암묵적 ambient stack 대신 채택된 이유 — Vide 경험자 대상 비교 -**2026-08-06 후속 세션에서 추가된 후보(둘 다 `research/ +**2026-08-06~07 후속 세션에서 추가된 후보(전부 `research/ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: -3. 왜 Batch/Transaction이 없는가 — Solid `batch()`/MobX `runInAction()`류 - lexical transaction이 Roblox의 협조적 스케줄링(코루틴 yield) 환경에서 - 왜 근본적으로 위험한지(전역/코루틴 스코프 플래그가 새 코루틴 스폰·영구 - yield에 어떻게 깨지는지 구체 시나리오 포함) — Fusion/Vide 비교는 아니고 - "설계 원리"형 에세이라 Rustonomicon 패러디 취지(비슷한 프레임워크 - 설계자용)와 잘 맞음 +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` 같은 파생 프리미티브가 이 목표 위에서 + 왜 자연스럽게 나왔는지까지 포함하는 설계 철학 에세이 **publish 안 하는 것과의 경계**: 세션별 정정 이력, 조사 원자료(Fusion 반응 그래프 BFS 분석, quad2-try 죽은 코드 조사 등)는 quadnomicon에도 From c5e0b7f452dacf941375109098accaa14a42993e Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 00:26:16 +0900 Subject: [PATCH 25/62] =?UTF-8?q?fix(base):=20Store=EC=9D=98=20Source=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=B4=20eager=EB=BF=90=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=9D=BC=20lazy=EB=8F=84=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=A8=EC=9D=84=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 여러 base 문서(store-semantics.md, bind-system-plan.md, architecture.md)에 "store.key는 Store 생성 시 이미 만들어둔 Source를 그대로 반환할 뿐"이라는 eager-only 서술이 반복돼 있었는데 부정확했음 — Luau 타입은 런타임에 강제되지 않고 defaults도 선택이라, Store<>() 처럼 defaults 없이 만든 뒤 .Key:Set(v)를 부르면 eager 생성만으론 .Key가 nil이라 크래시남. 정정된 모델: Store 생성 시점의 eager 생성(각 defaults 키마다 미리 Source 생성, 여전히 필요) + store.key 접근 시점의 lazy __index 생성(아직 없는 키를 그 자리에서 만들어 저장, 재접근시 재생성 없음) 둘 다 필요. bind-system-plan.md에 남아있던 관련 stale 서술도 같이 정정: - "__newindex/__index 프록시로 감싸면 됨"은 이후 :Set() 전환으로 무효화됨 - "defaults 테이블 직접 mutate는 UB"는 최신 모델과 안 맞음(defaults는 라이브 백킹이 아니라 아직 안 만들어진 Source의 초기값 템플릿일 뿐이라 나중에 바꿔도 문제없음, UB 아님) question.md에 .value vs :Get()/:Pull() 읽기 접근자 이름 재검토 항목도 추가(아직 미결정 — Finalize는 기존 cleanup 계열 어휘와 충돌해 기각, Pull과 Get/Set 대칭 사이에서 검토 중). Co-Authored-By: Claude Sonnet 5 --- .claude/base/architecture.md | 14 ++++++++++---- .claude/base/bind-system-plan.md | 29 ++++++++++++++++++++--------- .claude/base/store-semantics.md | 32 ++++++++++++++++++++++++-------- .claude/question.md | 11 +++++++++++ 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 12700f8..998a51e 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -191,10 +191,16 @@ CollectionService 태그 등)를 흉내낼 필요가 없고, quad-base 자체 ## Store/State/Source 온톨로지 — 확정됨 (요약) Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 -접근하면 Store 생성 시 이미 만들어둔 그 Source 객체를 그대로 반환한다 -(별도 wrapper 없음 — **[2026-08-06 후속 세션 정정]** 원래 "매번 새 State를 -감싸 반환"이었으나, `Source`가 구조적으로 `State`를 만족하도록 재구성되며 -wrapper 계층 자체가 불필요해짐). 전파는 push-invalidate(신호만)/ +접근하면 이미 만들어져 있는 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보다 넓은 인터페이스를 가짐 — diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 300076f..506850b 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -535,9 +535,14 @@ read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최 **구조적으로 State를 만족(단방향 호환)** — `.value`/`:Get()`/`:With`/`:Compute` 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. - **Store** — Source들의 이름 붙은 모음, 그 이상 아님. `store.a`처럼 키로 - 접근하면 Store 생성 시 이미 만들어둔 **그 Source를 그대로 반환**(더 이상 - 별도 State wrapper를 매번 만들거나 따로 캐싱하지 않음 — Source 자체가 - 이미 State를 만족하므로 wrapper 계층 자체가 불필요해짐). + 접근하면 **이미 만들어진 Source가 있으면 그대로 반환, 없으면 그 자리에서 + 만들어 저장한 뒤 반환**(더 이상 별도 State wrapper를 매번 만들거나 따로 + 캐싱하지 않음 — Source 자체가 이미 State를 만족하므로 wrapper 계층 + 자체가 불필요해짐. **[정정, 2026-08-07]** "Store 생성 시 전부 eager하게만 + 만들어진다"는 이전 서술은 부정확 — `defaults`가 선택이고 Luau 타입이 + 런타임에 강제 안 되므로, 생성 시점 eager 생성(각 `defaults` 키)과 + `store.key` 접근 시점 lazy 생성(아직 없는 키를 그 자리에서 만듦)이 둘 다 + 필요함, 상세는 `base/store-semantics.md` 참고). - **State** — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 독립적 value 개념이 없음. `state(state)`로 기존 state의 결과를 받아 새 state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 @@ -658,12 +663,18 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사 `Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만 다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 - 같은 스타일로 지원(안 하고 `Store()`만 있어도 되지만, 구현이 쉬우면 - 지원) — 내부적으로 입력 테이블을 그대로 들고 있지 않고 `__real`/ - metatable 저장 + `__newindex`/`__index` 프록시로 감싸면 됨. 이후 - 사용자가 그 defaults 테이블 원본을 직접 mutate하는 건 UB로 둠(방어 - 로직 불필요 — 오늘 세션에서 반복 확인된 "드문 오용 케이스를 위해 - 구조를 복잡하게 만들지 않는다"는 태도와 일치). + 같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 + 됨, 순수 편의용 초기값 템플릿). + **[정정, 2026-08-07]** 아래 두 문장은 이후 라운드에서 정정된 옛 서술 — + 실제 메커니즘·mutate 취급은 `base/store-semantics.md` "Source가 State를 + 만족함" 절이 최종 소스: (a) "`__newindex`/`__index` 프록시로 감싸면 + 됨"은 이후 `store.key = value` 쓰기 문법 자체가 `:Set()`으로 옮겨가며 + `__newindex`는 더 이상 관여 안 함(읽기 쪽 `__index`는 "없으면 그 자리에서 + Source를 만들어 저장"하는 lazy 생성 용도로 여전히 필요, 위 store-semantics.md + 참고). (b) "defaults 테이블 원본을 직접 mutate하는 건 UB로 둠"도 최신 + 모델과 안 맞음 — `defaults`는 라이브 백킹 스토리지가 아니라 "아직 안 + 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 쓰이므로, 생성 + 후 원본을 바꿔도 문제없고 UB가 아님. **Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정** diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 3ff65d9..5ff73c8 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -118,14 +118,30 @@ State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하 `__index` 트릭(`base/modifier-plan.md`)과 같은 패턴이라 로직 중복이 생기지 않음. - **`RefSource` 같은 별도 타입은 불필요, `Store({defaults})`가 - 내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨** — `store.key`는 - Store 생성 시 이미 만들어둔 Source를 그대로 돌려줄 뿐, 매번 새로 만들거나 - 별도로 캐싱할 wrapper 객체 자체가 없음. 이전에 검토했던 "State를 - weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/캐싱 단계 자체가 - 사라짐). v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 - 없어 단순하게 쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 - 타입이 핵심 제약이라 그 전제 자체가 더 이상 안 맞고, 이번 정리로 Store는 - "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. + 내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨.** + `defaults`는 **선택**(안 줘도 됨, 순수 편의용) — `store.key`는 이미 + 만들어져 있는 키면 그 Source를 그대로 돌려주지만, **아직 안 만들어진 + 키면 그 자리에서 `Source(defaults의 해당 값 또는 nil)`을 만들어 저장한 + 뒤 돌려줌**([정정, 2026-08-07] eager 생성만으로 충분하다고 서술했던 + 이전 버전은 부정확 — Luau 타입은 런타임에 강제되지 않으므로 + `Store<>()`처럼 defaults 없이 만든 뒤 `.Key:Set(v)`를 부르는 + 경우, `__index`가 "없으면 그 자리에서 만들어 저장"까지 해주지 않으면 + `.Key`가 `nil`이라 크래시남 — 그래서 Store 생성 시점의 eager 생성(각 + `defaults` 키마다 미리 만들어둠, 이건 여전히 필요)과 `store.key` 접근 + 시점의 lazy 생성(아직 없는 키를 그 자리에서 만들어 저장, 이후 재접근은 + 재생성 없이 그대로 반환)이 **둘 다** 필요함). `defaults` 테이블 자체는 + 라이브 백킹 스토리지로 쓰이지 않고 "아직 안 만들어진 Source를 만들 때 + 참고하는 초기값 템플릿"으로 반복 참조될 뿐이라, Store 생성 후 원본 + `defaults` 테이블을 밖에서 바꿔도 문제없음(UB 아님 — 이 항목도 + `bind-system-plan.md`에 남아있던 "defaults 테이블 직접 mutate는 UB"라는 + 옛 서술과 충돌해 2026-08-07에 같이 정정함, 아래 참고). 별도 `__values`류 + 그림자 실값 저장소도 불필요 — Source 객체 자체가 저장소 역할을 함. 이 + 모델은 이전에 검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다 + (래퍼 생성/캐싱 단계 자체가 사라짐). v1이 모든 값을 Store 하나에 + 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게 쓰는 게 편해서"였다는 + 게 사용자의 회고적 재평가 — 지금은 타입이 핵심 제약이라 그 전제 자체가 + 더 이상 안 맞고, 이번 정리로 Store는 "이름 붙은 Source 모음, 그 이상 + 아님"으로 더 단순해짐. - **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 diff --git a/.claude/question.md b/.claude/question.md index 2acd39e..1a5e265 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -92,6 +92,17 @@ additional-primitives-plan.md`. 요지: 주기가 없다"는 기존 원칙과 이름이 충돌해 보일 수 있음), `Draw`(짧지만 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md` 1번 절 참고. +- **State/Source 읽기 접근자 — `.value` vs `:Get()`/`:Pull()`(3순위, + 2026-08-07 추가)**: 지금 `.value`는 `:Get()`을 감싼 계산 속성이고 "관용적 + 표기"로 앞세워져 있는데, 이게 quad가 명문화한 "관측해야 실체화된다" + 원칙을 프로퍼티 문법이 무디게 만드는 건 아닌지 재검토 중. `Finalize`는 + quad가 이미 "정리/해제"쪽 의미로 자리 잡아둔 단어(Effect의 leaf-death + cleanup, `retract`, `Destroying`)와 충돌 위험이 있어 기각 — 대신 + `Pull()`(아키텍처 문서 자체가 이 메커니즘을 부르는 "push-invalidate/ + pull-recompute"와 일치)을 검토했다가, 사용자가 `Get()`/`:Set()` 동사 + 짝을 맞추는 쪽이 더 나을 수 있다고 재고 중 — **아직 결정 안 됨**, + `.value` 자체를 유지할지(Ref처럼 진짜 non-lazy인 것에만 남길지)도 같이 + 볼 것. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ From 53d43dc90b3aaa1d25e7df99e8a1d7d16522aa20 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 00:31:19 +0900 Subject: [PATCH 26/62] =?UTF-8?q?docs(base):=20Get()=20=ED=99=95=EC=A0=95,?= =?UTF-8?q?=20Store=20eager=20=EC=83=9D=EC=84=B1=EC=9D=80=20table.clone+in?= =?UTF-8?q?-place=20=EA=B5=90=EC=B2=B4=EB=A1=9C=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽기 접근자 함수명은 Get()으로 확정(Pull 대비 우위 없음 + 기존 문서와 일치) — question.md에 반영, .value 존치 여부만 열린 질문으로 남김. - Store의 eager Source 생성 구현 스케치 추가: table.clone(defaults) 후 순회하며 각 슬롯을 Source(v)로 교체 — 빈 테이블에 키를 하나씩 넣는 것보다 해시 슬롯 재사용이 Luau VM에서 더 쌈. Source()(무인자)는 Source(nil)과 동치라는 점도 명시(lazy 생성 경로가 이 형태를 씀). Co-Authored-By: Claude Sonnet 5 --- .claude/base/store-semantics.md | 9 +++++++++ .claude/question.md | 21 ++++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 5ff73c8..c6ad017 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -142,6 +142,15 @@ State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하 게 사용자의 회고적 재평가 — 지금은 타입이 핵심 제약이라 그 전제 자체가 더 이상 안 맞고, 이번 정리로 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()`류 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 diff --git a/.claude/question.md b/.claude/question.md index 1a5e265..525d2e4 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -92,17 +92,16 @@ additional-primitives-plan.md`. 요지: 주기가 없다"는 기존 원칙과 이름이 충돌해 보일 수 있음), `Draw`(짧지만 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md` 1번 절 참고. -- **State/Source 읽기 접근자 — `.value` vs `:Get()`/`:Pull()`(3순위, - 2026-08-07 추가)**: 지금 `.value`는 `:Get()`을 감싼 계산 속성이고 "관용적 - 표기"로 앞세워져 있는데, 이게 quad가 명문화한 "관측해야 실체화된다" - 원칙을 프로퍼티 문법이 무디게 만드는 건 아닌지 재검토 중. `Finalize`는 - quad가 이미 "정리/해제"쪽 의미로 자리 잡아둔 단어(Effect의 leaf-death - cleanup, `retract`, `Destroying`)와 충돌 위험이 있어 기각 — 대신 - `Pull()`(아키텍처 문서 자체가 이 메커니즘을 부르는 "push-invalidate/ - pull-recompute"와 일치)을 검토했다가, 사용자가 `Get()`/`:Set()` 동사 - 짝을 맞추는 쪽이 더 나을 수 있다고 재고 중 — **아직 결정 안 됨**, - `.value` 자체를 유지할지(Ref처럼 진짜 non-lazy인 것에만 남길지)도 같이 - 볼 것. +- **State/Source 읽기 접근자 — 함수명은 `Get()`으로 확정(2026-08-07), + `.value` 존치 여부만 남음**: `Get()` vs `Pull()` 중 `Get()`으로 확정 — + 둘 다 "명시적 게터 호출"이라는 엔지니어링 기준에서 동급이라 승부처가 + 아니었고, 이미 문서 전체가 `Get()`을 쓰고 있어 바꿀 이유가 약함(`Finalize`는 + 앞서 quad의 "정리/해제" 어휘(Effect leaf-death cleanup, `retract`, + `Destroying`)와 충돌 위험으로 이미 기각). **아직 열려있는 건 `.value` + (Get()을 감싼 "관용적 표기" 계산 속성) 자체를 유지할지** — quad가 + 명문화한 "관측해야 실체화된다" 원칙을 프로퍼티 문법이 무디게 만드는 건 + 아닌지, 유지한다면 State/Source에도 둘지 아니면 진짜 non-lazy인 Ref에만 + 남길지. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ From 99ffea2f07f080ceac51ae22ee253a8c53c2f85a Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 00:40:57 +0900 Subject: [PATCH 27/62] =?UTF-8?q?decide(base):=20.value=EB=A5=BC=20Ref=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=ED=99=95=EC=A0=95,=20St?= =?UTF-8?q?ate/Source=EB=8A=94=20Get()=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State/Source의 값 읽기 접근자에서 .value(관용 표기)를 제거하고 :Get() 하나로 통일 — "관측해야 실체화된다" 원칙이 가장 날카롭게 느껴져야 할 지점에서 프로퍼티 문법이 그 느낌을 무디게 한다는 판단. .value 표기 자체는 폐기가 아니라 Ref 전용으로 좁혀짐(Ref는 lazy가 아니라 읽어도 계산이 안 트리거되므로 프로퍼티 문법이 정직함) — 이름 충돌 자체가 사라짐. bind-system-plan.md/store-semantics.md/architecture.md/debug-tooling-plan.md 전체의 .value 언급을 :Get()으로 갱신, question.md의 관련 열린 질문은 완전히 해소되어 제거. Co-Authored-By: Claude Sonnet 5 --- .claude/base/architecture.md | 3 +- .claude/base/bind-system-plan.md | 40 +++++++++++++++----------- .claude/base/store-semantics.md | 11 ++++--- .claude/question.md | 10 ------- .claude/research/debug-tooling-plan.md | 2 +- 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 998a51e..5519397 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -204,7 +204,8 @@ push-invalidate(신호만)/ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림. State는 쓰기 대상이 아니고, 값을 쓰는 경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — -`.value`/`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가). 값 하나만 +`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07] +읽기는 `:Get()` 하나로 통일 — `.value` 표기는 Ref 전용으로 좁혀짐). 값 하나만 다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀. `store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서 정식 확정됨 — **더 이상 열린 질문 아님**, 남은 건 정확한 API 이름뿐. diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 506850b..9103d6b 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -404,7 +404,7 @@ Modifier와는 다른 부류). ```lua local observer = state:Observer(function() - state.value + state:Get() end) Frame { @@ -419,7 +419,7 @@ retract/Destroy되면 자동으로 정리됨. 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(아래 "Store/State/Source 온톨로지" 절)이 그대로 적용됨: `fn`은 "뭔가 바뀌었으니 다시 확인하라"는 신호만 받고 새 값 자체는 안 받음 — - 위 예시처럼 `fn` 본문에서 `state.value`/`Get()`을 명시적으로 다시 + 위 예시처럼 `fn` 본문에서 `state:Get()`을 명시적으로 다시 읽어야 함. 자동으로 안 해주는 이유: 재계산이 진짜 필요한지가 다른 `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` @@ -532,7 +532,7 @@ read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최 아래 "State는 쓰기 대상이 아님" 절 이후 내용 및 `base/store-semantics.md`의 "Source가 State를 만족함" 절 참고): - **Source** — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). - **구조적으로 State를 만족(단방향 호환)** — `.value`/`:Get()`/`:With`/`:Compute` + **구조적으로 State를 만족(단방향 호환)** — `:Get()`/`:With`/`:Compute` 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. - **Store** — Source들의 이름 붙은 모음, 그 이상 아님. `store.a`처럼 키로 접근하면 **이미 만들어진 Source가 있으면 그대로 반환, 없으면 그 자리에서 @@ -557,7 +557,7 @@ Fusion식 eager 노드·생성순 정렬은 안 만듦** - 신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책). -- 실제 재계산은 `Get()`(또는 `.value` 인덱싱)이 호출되는 시점에만 일어남 — +- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 소비자가 없다는 판단. 유일하게 "즉시 반응해야 하는" 소비자는 store-bind @@ -571,14 +571,14 @@ Fusion식 eager 노드·생성순 정렬은 안 만듦** **전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** 위 pull-recompute 규칙을 State 하나의 재계산 메커니즘으로만 읽지 말고, -프로젝트 전역에 적용되는 원칙으로 명시함: **어떤 파생값도 `.value`/`Get()`로 +프로젝트 전역에 적용되는 원칙으로 명시함: **어떤 파생값도 `:Get()`으로 직접 읽히기(관측) 전까지는 계산되지 않는다.** 이 원칙은 State 자체뿐 아니라, State를 필드 값으로 담고 있는 다른 구조(예: `base/modifier-plan.md`의 Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 읽으면 그 순간이 바로 관측이고, 그 순간 계산이 확정됨. **주의 — 구조적 복사는 관측이 아님.** `table.clone`처럼 테이블 레퍼런스만 -복사하는 연산은 안에 담긴 State 핸들을 그대로 옮길 뿐 `.value`/`Get()`을 +복사하는 연산은 안에 담긴 State 핸들을 그대로 옮길 뿐 `:Get()`을 호출하지 않으므로 관측이 아니고, 계산을 트리거하지 않음. Modifier 체이닝 메소드가 `table.clone` 후 필드를 덮어쓰는 것(위 "Immutable 값 + clone 기반 체이닝")과 이 원칙이 충돌하지 않는 이유가 바로 이것 — clone은 그저 참조 @@ -625,23 +625,31 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사 단점이 있었음 — self가 raw 값이면 `fn` 호출 전에 항상 self를 먼저 `Get()`해야 하므로, `fn` 내부 로직이 with한 다른 값을 보고 "이 경우엔 self 계산 자체가 필요 없다"고 판단해도 이미 늦음(예: `:With(noprint)`이고 - `noprint.value == true`면 앞단 계산을 통째로 생략하고 싶은 경우). + `noprint:Get() == true`면 앞단 계산을 통째로 생략하고 싶은 경우). - **해결(사용자 확정)**: self도 raw 값이 아니라 **State 핸들 그 자체**를 `fn`의 포지셔널 인자로 넘긴다 — `fn(self: State)`, 내부에서 - `self.value`(또는 `self:Get()`)를 실제로 읽을 때만 계산이 트리거됨. - with한 값과 동일한 lazy 원칙을 self에도 그대로 적용 — 별도 - `ComputeWithout` 변형은 불필요, `Compute` 하나로 일관. -- `.value`는 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 - `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴 재사용) — - `:Get()`과 `.value` 둘 다 지원, `.value`가 관용적 표기. + `self:Get()`을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한 + lazy 원칙을 self에도 그대로 적용 — 별도 `ComputeWithout` 변형은 + 불필요, `Compute` 하나로 일관. +- **[정정, 2026-08-07] `.value`는 State/Source에서 제외, `:Get()`만 지원.** + 이전엔 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 + `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로 + `.value`/`:Get()` 둘 다 지원하고 `.value`를 관용적 표기로 앞세웠으나, + "관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서 + 프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출 + `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. `.value` + 표기 자체는 폐기하지 않고 **Ref 전용으로 좁힘**(Ref는 lazy가 아니라 + 값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 — 이 + 절 위쪽 "Ref 일반화" 절의 `.Value`가 그대로 유일한 존재가 됨, 이름 + 충돌 자체가 사라져 별도 표기 정리 불필요). - 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return - key1.value + store.key2.value end)` — `key1`은 이제 raw 숫자가 아니라 + key1:Get() + store.key2:Get() end)` — `key1`은 이제 raw 숫자가 아니라 State. **State는 쓰기 대상이 아님 — 확정, Source는 독립 공개 프리미티브로 격상** -- `.value`는 항상 읽기 전용. State에는 쓰기 API가 아예 없음. "State에 - `.value = x`를 허용하면 다른 source에서 파생된 state에 직접 쓰기가 +- `state:Get()`은 항상 읽기 전용. State에는 쓰기 API가 아예 없음. "State에 + 직접 쓰기 API를 허용하면 다른 source에서 파생된 state에 직접 쓰기가 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가 없음). - **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value` diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index c6ad017..18372c5 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -108,9 +108,12 @@ State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하 **확정 방향**: - **`Source`가 구조적으로 `State`를 만족(단방향 호환)** — State 자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable extends - Readable`와 같은 모양). Source는 State가 주는 모든 것(`.value`, - `:Get()`, `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 - 추가로 가짐. + Readable`와 같은 모양). Source는 State가 주는 모든 것(`:Get()`, + `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 + 추가로 가짐([정정, 2026-08-07] `.value`는 State/Source에서 제외되고 + `Get()`으로 통일됨, `.value` 표기는 Ref 전용으로 좁혀짐 — `base/ + bind-system-plan.md` "`:With`/`:Compute` — self 인자도 lazy 핸들로 + 통일" 절 참고). - **`:With`/`:Compute`는 Source에서도 항상 `State`를 반환** — Source 자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는 것과 동치. 구현은 metatable `__index` 델리게이션(Source의 메소드 @@ -287,4 +290,4 @@ Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processo 연산.) 최종 형태는 `:With(...)`로 의존성을 모으고 `:Compute(fn)`으로 파생 State를 만드는 것으로 확정 — `Store.Combine({a,b}, fn)`류 포지셔널 인자 방식은 기각됨. 정확한 lazy 인자 규칙(self/with 값 둘 다 -State 핸들로 넘기고 `.value`를 실제로 읽을 때만 계산)은 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. +State 핸들로 넘기고 `:Get()`을 실제로 읽을 때만 계산)은 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. diff --git a/.claude/question.md b/.claude/question.md index 525d2e4..2acd39e 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -92,16 +92,6 @@ additional-primitives-plan.md`. 요지: 주기가 없다"는 기존 원칙과 이름이 충돌해 보일 수 있음), `Draw`(짧지만 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md` 1번 절 참고. -- **State/Source 읽기 접근자 — 함수명은 `Get()`으로 확정(2026-08-07), - `.value` 존치 여부만 남음**: `Get()` vs `Pull()` 중 `Get()`으로 확정 — - 둘 다 "명시적 게터 호출"이라는 엔지니어링 기준에서 동급이라 승부처가 - 아니었고, 이미 문서 전체가 `Get()`을 쓰고 있어 바꿀 이유가 약함(`Finalize`는 - 앞서 quad의 "정리/해제" 어휘(Effect leaf-death cleanup, `retract`, - `Destroying`)와 충돌 위험으로 이미 기각). **아직 열려있는 건 `.value` - (Get()을 감싼 "관용적 표기" 계산 속성) 자체를 유지할지** — quad가 - 명문화한 "관측해야 실체화된다" 원칙을 프로퍼티 문법이 무디게 만드는 건 - 아닌지, 유지한다면 State/Source에도 둘지 아니면 진짜 non-lazy인 Ref에만 - 남길지. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index 7425616..e56d26e 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -257,7 +257,7 @@ trace 이벤트 페이로드는 처음부터 함수/클로저 없이 **순수 ### 6. "관측해야 실체화된다" 원칙 — quad-debug 자신이 위반하면 안 됨 -`bind-system-plan.md`의 전역 원칙: 어떤 파생값도 `.value`/`Get()`으로 직접 +`bind-system-plan.md`의 전역 원칙: 어떤 파생값도 `:Get()`으로 직접 읽히기 전까지 계산되지 않음. quad-debug UI가 트리뷰를 그리면서 모든 State를 자동으로 펼쳐 값을 미리 읽어버리면, 원래 필요 없었을 계산을 디버그 도구가 유발하는 부작용이 생김 — **디버그 도구 자체도 lazy해야 함**: 사용자가 UI에서 From 5e837105c985dbdff33d5118e24d7fcf9d88d466 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 00:43:44 +0900 Subject: [PATCH 28/62] =?UTF-8?q?decide(base):=20Ref=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=A0=9C=EB=84=A4=EB=A6=AD=EC=9D=80=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20Ref(T)?= =?UTF-8?q?=EB=A1=9C=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React useRef(U):T|U류 2파라미터 분리 설계 검토했으나, 명시 타입 파라미터 하나 + 인자 추론 타입 파라미터 하나가 만드는 합집합이 Luau 솔버에서 깔끔히 안 풀리고 미해소 제네릭 변수가 남는 것으로 확인(사용자 직접 확인) — Source satisfies State, State 차단 검증에서 이미 반복된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다" 패턴과 같은 결. 단일 파라미터로 단순화, 초기값만으로 좁게 추론되는 문제(Ref(nil)->Ref)는 명시적 제네릭 적용(Ref<>(nil))으로 해결 — React useRef도 명시 타입 인자 없이는 같은 문제를 겪으므로 이미 받아들여진 UX. Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 9103d6b..cc73dfd 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -205,6 +205,22 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 "이미 채워졌는지" 확인이 항상 필요함). `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 nil 처리, Luau의 일반화 for는 성긴 배열도 잘 순회함). +- **제네릭 시그니처(2026-08-07 확정): `Ref(T) -> Ref` — 단일 타입 + 파라미터.** React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 + 분리"하는 2파라미터 설계도 검토했으나(예: `Ref<>(null)` + → `HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와 + 인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게 + 풀리지 않고 미해소 제네릭 변수가 결과 타입에 남는 것으로 확인(사용자가 + 직접 Luau 플레이그라운드류로 확인) — `Source satisfies State`나 + `State` 차단 검증 항목(`research/pre-implementation-audit.md`) + 에서 이미 반복 확인된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다"는 + 패턴과 같은 결. 단일 파라미터로 단순화하면 이 위험 자체가 없음 — 대신 + 초기값만으로 좁은 타입이 추론되는 문제(`Ref(nil)`이 `Ref`로 + 좁혀짐)는 `Ref<>(nil)`처럼 **명시적 제네릭 적용**(`f<>(...)` + 패턴, `.claude/initreq/tbox/CLAUDE.md:40-41` 선례)으로 타입을 넓혀 + 풀면 됨 — React `useRef(null)`도 명시적 타입 인자 없이는 + 같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는 + 트레이드오프 아님. - **`CreatedRef`와의 관계**: 둘은 상충하지 않음 — 이 절의 Ref가 범용 프리미티브, `CreatedRef(fn, {phase=...})`는 그 위에 얹힌 "children 배열에 넣으면 dispatch가 자동으로 채워주는" 특수 편의 패턴(quad가 From ec7469f74f0922a57e8fee17357e101ebc431a71 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 11:34:22 +0900 Subject: [PATCH 29/62] =?UTF-8?q?decide(base):=20With=EB=8F=84=20=EC=83=88?= =?UTF-8?q?=20State=20=EB=85=B8=EB=93=9C=EB=A1=9C=20=ED=99=95=EC=A0=95,=20?= =?UTF-8?q?=EA=B0=80=EB=B3=80=EC=9D=B8=EC=9E=90=EB=A1=9C=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EB=82=A8=EB=B0=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clone 기반 빌더 대안은 디버그 그래프 1:1 매핑을 깨고, Compute 노드 위에 clone하면 캐시 슬롯이 복사되어 계산이 중복 실행됨(State 체인 플래튼 기각과 같은 실패 모드). With(...)를 가변인자로 만들어 노드 남발 걱정을 해소. Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 50 ++++++++++++++++++++++++++++++++ CLAUDE.md | 35 ++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index cc73dfd..7dc298f 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -635,6 +635,56 @@ State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 워 Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사용자를 위한 좋은 심화 콘텐츠 소재. +### `:With`도 새 State 노드로 확정, 가변인자로 체인 남발 방지 (2026-08-07) + +**문제 제기(사용자)**: `:With(...)`가 문서상 가변인자 표기이긴 한데, 실제로 +호출마다(`:With(a):With(b):With(c)`처럼 체이닝할 때) 매번 새 State 노드를 +만드는 게 맞는지, 아니면 값 없이 의존성 목록만 clone-then-append로 누적하는 +가벼운 빌더로 만들어 노드 증식을 피해야 하는지가 불명확했음. + +**"빌더" 대안은 기각.** 세 가지 이유: + +1. **디버그 그래프가 꼬임.** `quad-debug`의 핵심 UX는 "무엇이 무엇에 + 연결됐는가" 그래프(`research/debug-tooling-plan.md`). With/Compute를 + 전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로 + 대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는 + 지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음. +2. **다이아몬드 dedup을 못 타고 특수 케이스가 생김.** With가 진짜 노드면 + `w = key1:With(key2)`에서 갈라지는 `c1 = w:Compute(g1)`, `c2 = + w:Compute(g2)` 같은 흔한 fan-out이 이미 확정된 "invalid 플래그로 + 다이아몬드 중복 워크 방지" 장치(위 "전파 모델 확정" 절)를 그대로 + 재사용함. 빌더면 c1/c2가 key1/key2에 각자 직접 구독을 걸어야 해서 + 기존 dedup 경로를 매번 우회하는 특수 케이스가 생김. +3. **clone 기반 구현은 Compute 노드 위에서 실제로 깨짐(사용자 지적, + 검증 완료).** `c = a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로 + 구현하면, `table.clone`이 `c`의 캐시 슬롯(계산된 값 + `invalid` + 플래그)까지 그대로 복사해 `w`가 `c`와 별개의 독립 캐시를 갖는 사실상 + 다른 노드가 됨. `c`와 `w`가 각자 관측되면 `f`가 두 번 따로 + 실행/캐싱됨 — 바로 위 "왜 State 체인을 Modifier처럼 플래튼하지 + 않는가" 절에서 이미 기각한 것과 정확히 같은 실패 모드(공유돼야 할 + 계산이 소비자 수만큼 중복 실행). Modifier의 clone-then-append 패턴을 + State 쪽에 그대로 가져오면 안 되는 이유가 바로 이것. + +**결정**: `:With(...)`는 호출마다 self+주어진 인자들을 구독하는 **새 State +노드**를 만든다(레퍼런스 기반 구독, clone 아님) — 계산 함수는 없고 값은 +`self`를 그대로 통과(pass-through)시키되 구독 목록만 넓힌 얇은 노드. 이 +노드는 Observer와 같은 패턴(외부 weak table)으로 상위 노드의 구독자 목록에 +등록됨. + +**노드 증식 걱정은 가변인자로 해소.** 처음 문제 제기("With 하나마다 노드가 +하나씩 늘어나는 게 낭비 아니냐")는 노드 자체를 없애는 대신, `:With(...)`가 +여러 의존성을 한 번에 받을 수 있게 해서 해소함: + +- `key1:With(a, b, c):Compute(fn)` — 노드 1개(구독 3개)로 끝남. +- `key1:With(a):With(b):With(c):Compute(fn)` — 여전히 가능하지만 노드 + 3개가 만들어짐. 이건 나쁜 게 아니라 각 노드가 dedup/디버그 그래프에서 + 실제 역할(구독 fan-in 지점)을 하는 저렴한 노드(계산 없음, Modifier + clone과 같은 급의 비용)라 걱정할 비용이 아님. +- 그래도 **가변인자 스타일을 권장 관례로 삼음** — 그래프로 그릴 때도 + `:With(a, b, c)`가 `:With(a):With(b):With(c)`보다 단순(노드 1개에 들어오는 + 엣지 3개 vs 노드 3개가 순서대로 이어지는 모양)해서 디버그하기 쉬움 + (사용자 확인). + **`:With`/`:Compute` — self 인자도 lazy 핸들로 통일** - 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 diff --git a/CLAUDE.md b/CLAUDE.md index e5f642c..4c9c69c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -569,3 +569,38 @@ architecture.md` 소스트리 주석 두 곳 — `Store.luau`가 여전히 옛 ` **다음 세션이 할 일**: M0 착수 전에 `pre-implementation-audit.md` 우선순위1 항목(특히 위 6개)부터 확인 — "지금 할 일" 1번 참고. `.claude/question.md` 2번에 사용자 판단이 필요한 항목 요약이 반영돼 있음. + +## 2026-08-07 세션 — `:With`도 새 State 노드로 확정 + +사용자 질문에서 시작: `:With(...)`가 문서상 가변인자 표기이긴 한데, 체이닝 +(`:With(a):With(b):With(c)`)할 때마다 실제로 새 State 노드를 만드는 게 +맞는지, 아니면 값 없이 의존성 목록만 clone-then-append로 누적하는 가벼운 +빌더로 만들어 "노드가 With 호출마다 하나씩 증가하는" 낭비를 피해야 하는지가 +불명확했음. 처음엔 "빌더" 대안(진짜 State가 아닌 clone 기반 누적 객체)을 +검토했으나, 사용자가 두 가지 반례를 직접 제시하며 기각함: + +1. **디버그 그래프가 꼬임** — `quad-debug`의 핵심 UX가 "무엇이 무엇에 + 연결됐는가" 그래프인데, With/Compute가 전부 실제 노드면 코드 호출 + 체인이 그래프 엣지와 1:1 대응되지만, 빌더로 만들면 그래프 툴이 가상의 + 분기 지점을 따로 합성해야 함. +2. **clone 기반 구현이 Compute 노드 위에서 실제로 깨짐** — `c = + a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로 구현하면 `c`의 캐시 + 슬롯까지 그대로 복사되어 `w`가 `c`와 별개의 독립 캐시를 갖게 되고, + `c`/`w`가 각자 관측되면 `f`가 두 번 따로 실행됨 — `bind-system-plan.md`가 + 이미 기각해둔 "State 체인 플래튼"과 정확히 같은 실패 모드. + +**결정**: `:With`는 호출마다 self+인자들을 레퍼런스로 구독하는 새 State +노드를 만든다(clone 아님, 계산 없는 pass-through 노드). 원래 문제 제기 +(노드 남발)는 노드를 없애는 대신 `:With(...)`를 진짜 가변인자로 만들어 +해소 — `:With(a, b, c)` 한 번으로 노드 1개(구독 3개)를 만들 수 있고, +디버그 그래프도 이쪽이 더 단순해 권장 관례로 삼음. 체이닝 스타일도 여전히 +가능하나 그건 저렴한 노드가 늘어나는 것뿐이라 문제 삼을 비용이 아님. +`base/bind-system-plan.md`의 "왜 State 체인을 Modifier처럼 플래튼하지 +않는가" 절 바로 뒤에 새 소절로 반영 완료. 다른 문서(`question.md`/ +`ROADMAP.md`/`modifier-plan.md`)엔 이 결정과 모순되거나 갱신이 필요한 +서술 없음을 확인함(감사 완료) — `modifier-plan.md`가 이미 "State가 +`:With`/`:Compute`마다 새 노드를 할당"이라고 서술해뒀던 것과도 정합적. + +다음 세션이 할 일은 안 바뀜(위 2026-08-06 네 번째 세션 절 참고) — 이 +결정은 M0 스파이크(Store/State propagation 검증)가 실제로 짜볼 때 +참고할 구체 스펙이 하나 더 생긴 것뿐. From 7901ea96c492bd03b45e66d2a0a65ce5b932d072 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 11:52:39 +0900 Subject: [PATCH 30/62] =?UTF-8?q?decide(base):=20Modifier=20:Apply(factory?= =?UTF-8?q?)=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EC=B2=B4=EC=9D=B4=EB=8B=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose 확장 함수 패턴을 콤비네이터로 흉내낸 얇은 sugar로 확정 — modifier-plan.md 8번 절, ROADMAP.md M7 체크박스, CLAUDE.md 세션 요약 반영. --- .claude/base/modifier-plan.md | 32 ++++++++++++++++++++++++++++++++ CLAUDE.md | 18 ++++++++++++++++++ ROADMAP.md | 2 ++ 3 files changed, 52 insertions(+) diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 3e42e78..401c772 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -198,6 +198,38 @@ State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어 아예 못 넣게 막을 것**(`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 필드 이름으로 예약됨**(실제 +스타일 프로퍼티 이름과 겹칠 일은 거의 없어 보이지만 문서화 필요). + ## 열린 질문 (`.claude/question.md`에도 취합) - **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter diff --git a/CLAUDE.md b/CLAUDE.md index 4c9c69c..91fda0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -604,3 +604,21 @@ architecture.md` 소스트리 주석 두 곳 — `Store.luau`가 여전히 옛 ` 다음 세션이 할 일은 안 바뀜(위 2026-08-06 네 번째 세션 절 참고) — 이 결정은 M0 스파이크(Store/State propagation 검증)가 실제로 짜볼 때 참고할 구체 스펙이 하나 더 생긴 것뿐. + +## 2026-08-07 두 번째 세션 — Modifier `:Apply(factory)` 팩토리 체이닝 추가 + +사용자 제안: `Boldify(mod) -> mod`처럼 어떤 modifier든 받아 적절히 변형해 +돌려주는 재사용 가능한 "팩토리 함수"(커링 지원, `Boldify(10)(mod) -> mod`)를 +`mod:Apply(Boldify(10)):Apply(Italicify)`처럼 기존 필드 setter 체이닝과 +같은 fluent 문법으로 끼워 넣을 수 있게 하자는 것 — Jetpack Compose의 커스텀 +`Modifier` 확장 함수 패턴과 같은 효용(모듈화된 스타일 프리셋 재사용)을 +Luau엔 확장 함수 문법이 없으니 콤비네이터로 흉내낸 아이디어. 채택 확정, +`base/modifier-plan.md` 8번 절에 반영 — `:Apply`는 `function(self, factory) +return factory(self) end`이 전부인 얇은 sugar(팩토리 자신이 이미 clone된 +새 Modifier를 반환하므로 Apply 자체는 clone 불필요), 기존 3번(immutable +clone 체이닝)/4번(제네릭 `__index`) 결정 위에 그대로 얹힘. 구현 시 주의점 +하나만 새로 생김: `Apply`는 제네릭 `__index`가 필드 setter를 즉석 합성하기 +전에 먼저 확인해야 하는 고정 메소드 이름이라, **Modifier 필드 이름으로는 +예약됨**(실 스타일 프로퍼티와 겹칠 일은 거의 없어 보이나 문서화 필요). +`ROADMAP.md` M7에 체크박스 추가 완료. 다음 세션이 새로 알아야 할 건 없음 — +M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. diff --git a/ROADMAP.md b/ROADMAP.md index a794898..30900ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,6 +80,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] flatten-before-dispatch, immutable `table.clone` 체이닝 - [ ] `Modifier.Merge`(가칭) - [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) +- [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 + `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) ## M8 — Ref From e4d6181fcf56d3929ed81bdf9a1d76658ad06f09 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 13:38:06 +0900 Subject: [PATCH 31/62] =?UTF-8?q?decide(base):=20Ref/PreRef=20=EB=94=94?= =?UTF-8?q?=EC=8A=A4=ED=8C=A8=EC=B9=98=20=ED=83=80=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=ED=99=95=EC=A0=95,=20phase=20=EC=98=B5=EC=85=98=EC=9D=80=20arc?= =?UTF-8?q?hive=EB=A1=9C=20=EC=97=AD=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreatedRef의 {phase="created"|"mounted"} 옵션 폐기, 위치 기반 순서로 대체 - base 디스패치가 배열 파트(children/Ref)를 해시 파트(프로퍼티/이벤트)보다 먼저 처리하도록 명시적으로 두 패스 계약화 - PreRef 신설: 프로퍼티/이벤트보다도 먼저 채워져야 하는 케이스(Roblox ChildAdded/DescendantAdded/Changed의 동기 발화 대응) 전용, Modifier/ Store 타입 차단 + 위치 무관 호이스팅 - Ref 콜백/대기자 실행 구현 디테일(coroutine vs function 분기) 추가 - 역전된 원 서술은 archive/ref-phase-option-reversed.md로 보존 - architecture.md/question.md/documentation-content-map.md/ROADMAP.md 동기화 Co-Authored-By: Claude Sonnet 5 --- .claude/archive/ref-phase-option-reversed.md | 74 +++++++++++ .claude/base/architecture.md | 2 +- .claude/base/bind-system-plan.md | 125 ++++++++++++++++-- .claude/base/modifier-plan.md | 17 +++ .claude/question.md | 4 + .claude/research/documentation-content-map.md | 2 +- CLAUDE.md | 54 ++++++++ ROADMAP.md | 5 + 8 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 .claude/archive/ref-phase-option-reversed.md diff --git a/.claude/archive/ref-phase-option-reversed.md b/.claude/archive/ref-phase-option-reversed.md new file mode 100644 index 0000000..69403fa --- /dev/null +++ b/.claude/archive/ref-phase-option-reversed.md @@ -0,0 +1,74 @@ +# [역전됨] `CreatedRef`의 `phase` 옵션 + "Ref는 특수 처리 없는 평범한 참가자" — 위치 기반 순서 + `PreRef` 신설로 대체됨 + +**역전 일시**: 2026-08-07 (세 번째 세션). **원 확정 일시**: 2026-08-04 +(Ref 도입 확정 절)~2026-08-06(Ref 일반화 절)에 걸쳐 누적 확정. +**현재 유효한 설계**: `base/bind-system-plan.md`의 "확정된 디스패치 +모델" 절 하단(배열/해시 두 패스 계약)과 "`phase` 옵션 폐기 → +위치로 표현, `PreRef` 신설" 절이 최종 소스. 이 파일은 더 이상 능동적으로 +참고할 필요 없음(구현에 안 씀) — 왜 "옵션 하나로 phase를 고르는 설계"에서 +"위치 기반 순서 + 별도 타입 분리"로 넘어갔는지가 `quadnomicon`(프레임워크 +설계자용 심화 콘텐츠) 소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**1. Ref는 dispatch 레지스트리의 "평범한 참가자"였음** (2026-08-04 +원문, `bind-system-plan.md` "Ref — 도입 확정" 절): + +> **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯 +> `CreatedRef` 같은 값을 숫자 키 슬롯에 넣는 방식 — child와 동일한 +> 층위에서 `process(inst,k,v)` 디스패치를 그대로 타게 함. 즉 Ref도 +> pluggable 핸들러 레지스트리의 평범한 참가자. + +**2. "생성 직후"/"마운트 후" 두 타이밍은 옵션 값으로 골랐음** (같은 절): + +> **콜백 호출 시점 확정**: 생성 직후(construction 시점)와 트리 마운트 +> 후(Parent 세팅 완료) 둘 다 지원 — 옵션으로 선택(`CreatedRef(fn, +> {phase="created"|"mounted"})`류, 정확한 API 이름은 구현 단계에서 확정). + +당시엔 이 두 문장이 서로 모순되지 않는다고 봤음 — "평범한 참가자"이면서 +동시에 "옵션으로 두 시점 중 골라 fire"하는 게 가능하다고 전제했던 것. + +## 역전된 이유 + +실제 사용 시나리오를 짚다가 드러남: quad-roblox 이벤트는 `self(Instance)`를 +안 주기로 이미 확정돼 있어서(`base/bind-system-plan.md` "이벤트 핸들러는 +self를 받지 않는다"), 이벤트 안에서 인스턴스 자신을 참조하려면 Ref로 +받아둔 값을 읽는 수밖에 없음. 그런데 Roblox 이벤트 중 일부(`ChildAdded`/ +`DescendantAdded`/`Changed`류)는 유저 인터랙션을 기다리지 않고 **setup +도중 프로퍼티 대입/Parent 세팅 자체의 부작용으로 동기적으로 발화**할 수 +있음 — 이 시점에 self-ref가 아직 안 채워져 있으면 그대로 터짐. + +이 문제를 실제로 풀려고 보니 "phase 옵션 하나로 고르는 평범한 참가자" +모델이 두 가지를 보장하지 못한다는 게 드러남: +1. **"평범한 참가자"라는 전제 자체가 Modifier/Store를 거치면 깨짐.** + Ref가 Modifier 필드로 flatten되거나 Source/Store 값으로 나중에 + 도착하면, "이 인스턴스에 다른 무엇보다 먼저"라는 순서 보장을 구조적으로 + 줄 방법이 없음(Modifier flatten은 해시 파트로 합쳐지고, Store 값은 + process/retract 재귀 경로로 원래 스캔보다 나중에 도착하므로). +2. **"created" phase가 실제로 뭘 보장하는지가 원래 정의돼 있지 않았음.** + "생성 직후"가 "다른 모든 프로퍼티/이벤트보다 먼저"까지 보장하는 건지, + 아니면 "그냥 루프 어딘가에서, construction 이후"면 충분한 건지가 + 불명확했음 — 후자로 해석하면 옵션이 무의미해지고, 전자로 해석하면 + `process(inst,k,v)` 우선순위 스캔만으로는 줄 수 없는 순서 보장이라 + 드라이버 레벨 개입이 필요해짐. + +## 이전 것과 지금 것의 차이 + +| | phase 옵션(역전됨) | 위치 기반 + `PreRef`(현재) | +|---|---|---| +| "자식 마운트 전/후" 표현 | `{phase="created"\|"mounted"}` 옵션 값 | children 배열에서 다른 형제보다 앞/뒤에 놓는 것만으로 결정(두 패스 계약 위에서 공짜로 나옴) | +| "프로퍼티/이벤트보다 먼저" 표현 | 같은 옵션의 `"created"` 값 — 실제로 이 보장을 줄 메커니즘은 없었음 | 별도 nominal 타입 `PreRef` — Modifier/Store엔 타입으로 아예 못 들어가고, 배열 파트 스캔 전에 driver가 따로 pre-pass로 fire, 위치와도 무관하게 항상 최우선(호이스팅) | +| Ref/CreatedRef가 참가자로서 특수한지 | "특수 처리 없이, 평범한 참가자"라고 명시 | 일반 Ref/CreatedRef는 여전히 평범한 `(v=Ref)` 핸들러 매치 — 다만 그 매치가 성립하려면 base가 배열 파트/해시 파트 순서를 **명시적으로 계약화**해야 했음(우연한 Luau 테이블 동작에 기대지 않음), `PreRef`는 아예 별도 pre-pass 대상이라 진짜 특수 취급 | +| Store/Modifier 조합 가능 범위 | 논의 안 됨(암묵적으로 전부 가능하다고 전제) | 일반 Ref는 자유, `PreRef`는 타입으로 원천 차단 | + +## 왜 완전히 헛수고는 아니었나 + +"children 배열 슬롯에 넣으면 dispatch가 채워준다"는 `CreatedRef`의 +핵심 아이디어 자체는 그대로 살아남음 — 바뀐 건 "그 안에서 두 타이밍을 +옵션 하나로 고르게 하자"는 세부 설계뿐. 오히려 이 반전 덕분에 "왜 굳이 +`PreRef`라는 별도 타입이 필요한가"(=순서 보장이 안 되는 경로가 실제로 +있다는 것)와 "base 드라이버가 왜 배열/해시 순서를 명시적으로 계약화해야 +하는가"(=Lua 테이블의 우연한 동작에 기대면 다른 백엔드에서 깨질 수 +있다는 것) 두 가지가 훨씬 선명해짐 — `quadnomicon`에서 "옵션 하나로 +퉁치려던 설계가 실제 시나리오(Roblox 이벤트의 동기 발화)를 만나 타입 +분리로 귀결된 사례"로 쓰기 좋음. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 5519397..8e27a9d 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -132,7 +132,7 @@ quad/ │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) │ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" -│ ├── Ref.luau # 범용 값 박스(.Value/:Wait()/콜백) + 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 +│ ├── Ref.luau # 범용 값 박스(.Value/:Wait()/콜백) + 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 + PreRef(children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화, `bind-system-plan.md` "PreRef 신설" 절) │ └── init.luau └── quad-roblox/ ├── wally.toml diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 7dc298f..59e30d8 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -110,6 +110,26 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 78-79행)처럼 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 아니라고 판단해 별도 가드 없이 진행. +- **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 + 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, + 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau + 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 + 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, + `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, + **base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자 + 키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리. + 이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌 + 다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에 + 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 + 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 + 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 + 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 + 먼저 처리된다는 게 base 자체의 보장**이 됨 — 아래 "Ref 일반화" 절 뒤에 + 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제 + Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 + 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 + 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -157,16 +177,15 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 - Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). -- **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯 `CreatedRef` - 같은 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: - `[1] = CreatedRef(function(inst) ... end)`) — child와 동일한 층위에서 - `process(inst,k,v)` 디스패치를 그대로 타게 함. 즉 Ref도 pluggable 핸들러 - 레지스트리의 평범한 참가자. -- 코루틴 기반 "채워질 때까지 대기" 지원 여부는 여전히 미정(별도 확인 필요 없이 - 구현 우선순위 낮음 — 필요성이 명확해지면 그때 추가). -- **콜백 호출 시점 확정**: 생성 직후(construction 시점)와 트리 마운트 후(Parent - 세팅 완료) 둘 다 지원 — 옵션으로 선택(`CreatedRef(fn, {phase="created"| - "mounted"})`류, 정확한 API 이름은 구현 단계에서 확정). +- **바인드 방법**: children을 배열 아이템으로 넣듯 `CreatedRef` 같은 + 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: + `[1] = CreatedRef(function(inst) ... end)`) — `(v=Ref)` 매치 핸들러가 + 이걸 처리함. **[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 + 마운트 전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — + 아래 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, + 원래 있던 "옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 + 없는 평범한 참가자" 서술은 `archive/ref-phase-option-reversed.md`로 + 옮김. - **왜 값이 아니라 콜백인가**: quad는 React처럼 렌더 함수가 계속 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, `useEffect` @@ -205,6 +224,15 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 "이미 채워졌는지" 확인이 항상 필요함). `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 nil 처리, Luau의 일반화 for는 성긴 배열도 잘 순회함). + - **구현 디테일(2026-08-07 세 번째 세션, 사용자 제안)**: 값이 새로 + `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 + 한 번만 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 + 보고 `coroutine.resume(v, value)` 후 `[i] = nil`(1회성 소진), 아니면 + 일반 콜백 함수로 보고 그냥 `v(value)`(소진 안 함, 계속 유지)로 + 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 + 하나로 분기 가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈 + 인덱스가 있어도 없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도 + 일반화 `for`가 계속 잘 순회하므로 압축(compact)할 필요도 없음). - **제네릭 시그니처(2026-08-07 확정): `Ref(T) -> Ref` — 단일 타입 파라미터.** React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 분리"하는 2파라미터 설계도 검토했으나(예: `Ref<>(null)` @@ -244,6 +272,83 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 "범용 값 박스"로 넓어졌으므로, 진행 중인 용어 정리(`question.md` 1번) 때 이름이 여전히 맞는지 같이 재검토할 것. +### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 세션) + +**`CreatedRef(fn, {phase="created"|"mounted"})`의 `phase` 옵션 자체를 +없앤다.** 위 "확정된 디스패치 모델" 절에 새로 추가된 두 패스 보장(배열 +파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 +**일반 `Ref`/`CreatedRef`를** 다른 children보다 앞/뒤 어디에 놓느냐가 +이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 +서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어가므로, +"마지막에 놓기"만으로 "모든 자식 마운트 후" 의미가 공짜로 나옴. 별도 +옵션 문법을 유지할 이유가 없어짐. **(아래 `PreRef`는 이 위치-의존 규칙의 +예외 — 위치 영향을 아예 안 받고 호이스팅됨, 해당 절 참고.)** + +**단, "프로퍼티/이벤트 세팅보다도 먼저"는 위치만으론 못 푼다.** 배열 +파트가 해시 파트보다 항상 먼저 처리된다는 보장은 **그 인스턴스의 최초 +props 테이블에 리터럴로 존재하는 항목에 한정**됨 — Modifier를 거쳐 +flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store를 거쳐 +나중에 도착하는 값은 애초에 이 최초 스캔 자체를 벗어난 시점(process/retract +재귀 경로)에 도착하므로 이 보장 밖. 그런데 "프로퍼티보다 먼저 채워져야 +한다"가 실제로 필요한 이유가 있음 — quad-roblox 이벤트는 `self(Instance)`를 +안 주기로 확정했으니(아래 절) self 접근은 Ref로 해야 하는데, Roblox +이벤트 중 일부(`ChildAdded`/`DescendantAdded`/`Changed`류)는 유저 +인터랙션을 기다리지 않고 **setup 도중 프로퍼티 대입/Parent 세팅 자체의 +부작용으로 동기적으로 발화**할 수 있음 — 이때 이벤트 핸들러가 아직 안 +채워진 self-ref를 읽으면 터짐. + +**해결**: 이 케이스만 별도 타입 `PreRef`로 분리. +- **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Wait()`/콜백 API) — + 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. +- **오직 children 배열의 리터럴 아이템으로만 놓을 수 있다** — **Modifier + 필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.** + - Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로 + 존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가 + Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이 + 인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 — + 허용해도 얻는 유스케이스가 없는 오버엔지니어링. + - Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 + 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 + 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 + 없음 — `State`를 UB로 보고 타입으로 막기로 한 것과 정확히 + 같은 원칙의 재적용. +- **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 + `Ref`/`CreatedRef`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 + children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 + 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 + 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, + 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 + 마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가 + 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 + base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의 + 작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고, + 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 + 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 + 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 + 앞에 얹히는 것. +- **일반 `Ref`/`CreatedRef`는 계속 Modifier/Store 어디든 자유롭게 + 들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 + 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분). +- **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.** + `Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로 + 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — + v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠. +- **`:Wait()`는 PreRef에도 그대로 유효해야 함.** PreRef 자신의 fire는 + 항상 동기적이지만, `:Wait()`를 호출하는 코드가 `task.spawn`이 아니라 + 순수 `coroutine`로 실행 중이었다면(Roblox `task` 스케줄러의 순서 보장이 + 없는 컨텍스트) 호출 시점에 아직 안 채워져 있어 실제로 yield-resume이 + 필요한 경우가 생김 — "항상 동기적이니 `:Wait()`는 즉시 리턴할 것"이라고 + 단정해 구현을 특수화하면 안 됨, 그냥 보통 `Ref`와 동일한 대기자 + 리스트/coroutine.yield 구현을 그대로 씀. **문서화 필요**: "채워졌는지 + 먼저 확인, 없으면 `:Wait()`" 방어적 패턴을 권장 관용구로 명시(콜백이 + "이미 채워져 있으면 즉시 1회 호출"하는 것과 대칭되는, 값이 없을 수도 + 있다는 걸 항상 코드가 스스로 확인해야 한다는 Ref 전체의 일관된 원칙). +- **프로퍼티/이벤트가 항상 children/Ref보다 나중에 세팅된다는 사실 자체는 + "고치지" 않는다** — 두 패스 순서를 뒤집거나 재배치하는 시도는 + 오버엔지니어링으로 판단해 안 함(이걸 원하면 애초에 PreRef를 쓰면 됨). + 이 결정과 이유는 나중에 `quadnomicon` 콘텐츠로 문서화 예정 + (`research/documentation-content-map.md` 후보로 메모). + ## 이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06) **결정**: v1의 `function(self, ...)` 관습(`self`/`this`로 이벤트 대상 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 401c772..31d7133 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -43,6 +43,23 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두 규칙으로 쪼개야 함. +### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기"는 아직 안 풀림 — `None` 센티널 후보만 메모 (2026-08-07 세 번째 세션, 미확정) + +**문제**: `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 +명시적으로 취소하고 싶어도, Lua 테이블 리터럴에서 `키 = nil`은 그 키 +자체가 아예 존재하지 않는 것과 구별이 안 됨(`pairs`에서도 안 보임) — 그래서 +위 2번 "인라인은 무조건 우선" 규칙이 실제로 작동할 근거(인라인 키가 +존재한다는 사실 자체)가 사라지고, `mod`가 주는 `Override` 값이 그대로 +새어나옴. + +**후보(미확정)**: 이벤트 store-bind에서 이미 쓴 "`nil` 대신 실재하는 +센티널 값" 패턴(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 +store-bind 가능" 절) 재사용 — `None`(가칭) 프리미티브를 만들어 +`{ Override = None, mod }`로 쓰면 flatten 로직이 `None`을 만난 인라인 키를 +"명시적으로 지움"으로 해석해 modifier 쪽 값을 덮어씀. 상세 설계(타입, +flatten 내부 표현, State 필드에도 같은 문제가 적용되는지 등)는 다음 +세션에서 이어감 — 지금은 문제와 방향성만 기록. + ### 3. Immutable 값 + clone 기반 체이닝 컴포지션 트리를 타고 내려가며 조금씩 변형되는 modifier(문서 뷰어에서 상위 diff --git a/.claude/question.md b/.claude/question.md index 859fcc2..61fbcd8 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -82,6 +82,10 @@ additional-primitives-plan.md`. 요지: - **`CreatedRef`/`canExecute`(3순위, 사소함)**: `CreatedRef`는 과거분사형이라 생성자처럼 안 읽힘. `canExecute`는 실제로 "이 핸들이 아직 살아있나" 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. + **(2026-08-07 추가)** `PreRef`(children 배열 전용, Modifier/Store에 + 못 들어가는 Ref 특수화 — `base/bind-system-plan.md` "`phase` 옵션 폐기 → + 위치로 표현, `PreRef` 신설" 절)도 신규 이름이라 이 라운드에 같이 재검토 + 대상. - **`Ref`(3순위, 2026-08-06 추가)**: 정의가 "quad가 만든 instance를 얻는 통로"에서 "아무 사용자 값이나 담는 범용 값 박스"로 넓어져서(`base/ bind-system-plan.md` "Ref 일반화" 절), 이름이 여전히 넓어진 의미에 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 04a5836..4706b53 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -34,7 +34,7 @@ 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 참조/마이그레이션용, `CreatedRef(fn, {phase=...})` (`architecture.md`, `bind-system-plan.md`) +11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `CreatedRef(fn)` + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `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 한정)** — `Corner`/`PaddingAllOffset`/`Scale` 인라인 키 (`research/ui-shorthand-plan.md`) diff --git a/CLAUDE.md b/CLAUDE.md index 91fda0b..e853732 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -622,3 +622,57 @@ clone 체이닝)/4번(제네릭 `__index`) 결정 위에 그대로 얹힘. 구 예약됨**(실 스타일 프로퍼티와 겹칠 일은 거의 없어 보이나 문서화 필요). `ROADMAP.md` M7에 체크박스 추가 완료. 다음 세션이 새로 알아야 할 건 없음 — M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. + +## 2026-08-07 세 번째 세션 — Ref의 KV 핸들러 처리 vs phase 타이밍, `PreRef` 신설 + +**출발점**: Ref가 Modifier처럼 밖에서 처리되는 게 아니라 KV 핸들러 +(`process(inst,k,v)`)로 처리된다면, "생성 직후"/"자식 마운트 후" 두 +콜백 타이밍(특히 self(Instance)를 안 주는 이벤트가 Ref로 self를 얻는 +경우)을 단순 for-loop 디스패치만으로 어떻게 표현하는지가 출발 질문 — +길게 이어진 단일 스레드라 아래 요약만 읽으면 됨, 상세 근거는 각 base +문서에 이미 반영됨. + +**핵심 결론(전부 `base/bind-system-plan.md`에 반영 완료)**: +- **base 디스패치 드라이버는 props 순회를 "배열 파트(children/Ref) 먼저, + 해시 파트(프로퍼티/이벤트) 나중"으로 명시적으로 두 패스 계약화**한다 + — Luau 테이블이 실제로 이렇게 순회되는 걸 사용자가 직접 확인했지만, + 그 우연한 동작에 기대지 않고 base가 스스로 이 순서를 보장(다른 + 백엔드가 다른 자료구조를 쓸 수 있어서). M0 스파이크 검증 항목에 추가. +- **`CreatedRef`의 `{phase="created"|"mounted"}` 옵션은 폐기.** 두 패스 + 계약 덕에 "자식 마운트 전/후"는 그냥 배열 안에서 Ref를 다른 children + 보다 앞/뒤에 놓는 것만으로 공짜로 표현됨 — 옵션 문법 자체가 불필요. +- **`PreRef` 신설** — "프로퍼티/이벤트 세팅보다도 먼저"(Roblox의 + `ChildAdded`/`DescendantAdded`/`Changed`류가 setup 도중 동기 발화할 + 수 있어서 self-ref가 이벤트보다 먼저 채워져야 하는 케이스)만 담당하는 + 별도 nominal 타입. `Ref`를 그대로 재사용(런타임 중복 없음)하되 + Modifier 필드 값·Source/Store 값으로는 타입으로 아예 못 들어가게 + 막고, children 배열 안에서도 위치 무관하게 항상 최우선(호이스팅) — + base 드라이버가 두 패스 루프 앞에 `PreRef`만 골라 fire하는 좁은 + pre-pass를 하나 더 둠. +- **일반 `Ref`는 Modifier/Store 어디든 계속 자유롭게 들어감** — Store를 + 통해 나중에 도착하는 Ref는 그냥 도착한 순간 처리, 별도 phase 개념 불필요. +- **`:Wait()`는 PreRef에도 그대로 유효** — fire 자체는 동기적이지만 + 호출부가 `task.spawn`이 아니라 순수 `coroutine`일 수 있어 실제 + yield-resume이 필요한 경우가 있음. "채워졌는지 먼저 확인, 없으면 + `:Wait()`" 방어 관용구를 문서화 대상으로 명시. +- **콜백/대기자 실행 구현 디테일 추가**: 같은 배열 하나를 한 번의 + 일반화 `for`로 순회하며 `type(v)=="thread"`면 `coroutine.resume`+ + 슬롯 nil 처리(1회성), 함수면 그냥 호출(유지) — 새 등록은 `table.insert` + 로 끝, 성긴 배열이어도 압축 불필요. +- v1의 `OnCreated` 특수 DI 키는 이식 안 함 — `Ref():Callback(fn)`으로 + 완전 대체. + +**역전된 이전 서술은 archive로 이동**: `CreatedRef`의 `phase` 옵션과 +"Ref는 특수 처리 없는 평범한 참가자"라는 원래 서술은 +`archive/ref-phase-option-reversed.md`로 옮기고 원 위치엔 짧은 포인터만 +남김(컨텍스트 비대화 방지 목적, `archive/store-source-proxy-reversed.md`와 +같은 패턴). `architecture.md` 소스트리 주석/`question.md`(PreRef를 +용어 재검토 대상에 추가)/`research/documentation-content-map.md`(stale +`{phase=...}` 예시 갱신)도 같이 동기화함. + +**아직 미해결, 다음 세션 주제로 예고됨**: `{ Override = nil, mod }`처럼 +인라인 키로 modifier가 주는 값을 명시적으로 "지우고" 싶어도 Lua +테이블 리터럴의 `키 = nil`은 키가 아예 없는 것과 구별이 안 돼서 안 +풀리는 문제 — `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 `None` +(가칭) 프리미티브를 도입하는 방향만 `base/modifier-plan.md` "2-1"절에 +짧게 메모해두고 상세 설계는 다음 세션으로 미룸. diff --git a/ROADMAP.md b/ROADMAP.md index 30900ab..7861367 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 피할 수 있어 보이나 실제 검증 전엔 확정 아님) - [ ] `process`/`retract` 재귀 재-process 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) +- [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 + Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + + 일반 `Ref`/`CreatedRef`의 위치 기반 순서까지 최소 스파이크로 검증 + (2026-08-07 세 번째 세션, `base/bind-system-plan.md` "`phase` 옵션 + 폐기 → 위치로 표현, `PreRef` 신설" 절) - [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, `export type Params = {...}`로 타입 체크되는지 확인 (`component-composition-plan.md` 최종 결론 1번) From 5c9d10df669d29b71ef8b782f2a13e8386d9c35e Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 14:39:20 +0900 Subject: [PATCH 32/62] =?UTF-8?q?docs:=20.claude/=20=EC=BD=94=ED=8D=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20reference/=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4,=20=EC=8A=B9=EA=B2=A9/=EA=B8=B0=EA=B0=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,=20=EC=97=AD=EC=A0=84=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=AC=EB=B0=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base 밖으로 늘 읽을 필요 없는 참고자료(quad-v1-architecture, comparison-fusion-vide)를 새 reference/ 폴더로 분리하고, ui-shorthand-plan을 base로 승격(RoundSize 드롭+ UICorner/UIPadding/UIScale 리네임), additional-primitives-plan을 Blocker/Effect(base 승격)·Batch/Context(archive 기각)·키 기반 컬렉션 재조정(research 잔류)으로 4분할했다. component-composition-plan의 중복 역전 서사는 기존 archive 포인터로 압축하고, archive 제목 컨벤션을 [역전됨]/[기각됨]로 분화했다. tween-plan에는 retract/canExecute 구분 메모와 트윈 옵션 값 모양 논의를 추가했다. Effect가 Observer 변형인지는 임의로 결론내지 않고 question.md에 열린 질문으로 남겼다. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 29 +- .claude/archive/batch-rejected.md | 63 ++++ .claude/archive/context-rejected.md | 61 ++++ .../archive/ui-shorthand-roundsize-dropped.md | 44 +++ .claude/base/additional-primitives.md | 157 ++++++++++ .claude/base/architecture.md | 10 +- .claude/base/bind-system-plan.md | 6 +- .claude/base/component-composition-plan.md | 54 ++-- .claude/base/lifecycle-pattern.md | 2 +- .claude/base/modifier-plan.md | 2 +- .claude/base/slot-plan.md | 4 +- .claude/base/store-semantics.md | 11 +- .claude/base/ui-shorthand-plan.md | 108 +++++++ .claude/question.md | 78 ++--- .../comparison-fusion-vide.md | 8 +- .../quad-v1-architecture.md | 12 +- .../research/additional-primitives-plan.md | 282 ++---------------- .claude/research/debug-tooling-plan.md | 2 +- .claude/research/documentation-content-map.md | 10 +- .claude/research/documentation-plan.md | 2 +- .claude/research/pre-implementation-audit.md | 4 +- .claude/research/tween-plan.md | 52 +++- .claude/research/ui-shorthand-plan.md | 102 ------- .claude/research/v1-compat-plan.md | 2 +- CLAUDE.md | 76 ++++- ROADMAP.md | 26 +- 26 files changed, 731 insertions(+), 476 deletions(-) create mode 100644 .claude/archive/batch-rejected.md create mode 100644 .claude/archive/context-rejected.md create mode 100644 .claude/archive/ui-shorthand-roundsize-dropped.md create mode 100644 .claude/base/additional-primitives.md create mode 100644 .claude/base/ui-shorthand-plan.md rename .claude/{base => reference}/comparison-fusion-vide.md (92%) rename .claude/{base => reference}/quad-v1-architecture.md (88%) delete mode 100644 .claude/research/ui-shorthand-plan.md diff --git a/.claude/README.md b/.claude/README.md index d1d461b..99659a1 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -9,10 +9,11 @@ | 폴더 | 기준 | |---|---| -| `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식 | +| `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | +| `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | -| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용 — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음 | +| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것 | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | | `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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -25,8 +26,6 @@ | 문서 | 내용 | |---|---| | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) | -| `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선 | -| `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료(전파 모델 등 일부 서술은 이후 라운드에서 뒤집혔으니 `bind-system-plan.md` 쪽을 최신으로 볼 것) | | `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차 라운드) | @@ -34,20 +33,28 @@ | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정, getter 이름만 남음 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | -| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족(`StoreSource`/`RefSource` 중간안은 전부 폐기됨) — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐 [정정: 2026-08-04 승격됐으나 이 표에 반영이 안 돼있던 걸 2026-08-06 뒤늦게 수정] | +| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | +| `additional-primitives.md` | **[2026-08-07 신설]** `Blocker`(여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤과 함께 개발)와 `Effect`(leaf 죽음에 확정 정리) — Blocker는 메커니즘+이름 확정, Effect는 Observer와의 관계가 아직 미해결(문서 내 "미해결" 절, `question.md` 0번) | +| `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` 참고 | + +## `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` 소재 후보 | ## `research/` — 아직 착수 전, 상의 필요 | 문서 | 내용 | 우선순위 | |---|---|---| -| `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel | 중 — 세부 옵션만 남음 | +| `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 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개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 | -| `ui-shorthand-plan.md` | UICorner/UIPadding/UIScale 인라인 편의 키(v1 `Corner`/`PaddingAll`/`Scale` 선례) — 여전히 필요한 기능으로 재확정(RoundSize만 네이티브 UICorner로 대체돼 불필요), 메커니즘(Handler)·패키지 배치(quad-roblox 코어) 확정 | 하 — 결론 남, M10 전후 구현하면 됨 | -| `additional-primitives-plan.md` | 확정 프리미티브(Source/State/Store/Ref/Observer/Modifier/Slot/DI)만으로 충분한지 웹 프레임워크·Fusion/Vide/v1 소스 근거로 조사 — 키 기반 동적 컬렉션 재조정(Fusion `ForPairs`/Vide `indexes()`류)이 가장 명확한 빈 자리로 확인, Effect/Batch/Context는 부차적 후보 | 상 — 사용자 판단 대기, 착수 전 M0/M1 스코프에 영향 줄 수 있음 | +| `additional-primitives-plan.md` | **[2026-08-07 범위 축소]** 확정/기각된 Effect·Blocker·Batch·Context는 `base/additional-primitives.md`·`archive/`로 분리됨 — 이제 **키 기반 동적 컬렉션 재조정**(Fusion `ForPairs`/Vide `indexes()`류에 대응하는 프리미티브가 quad엔 없음) 하나만 다룸 | 상 — 사용자 판단 대기, 착수 전 M0/M1 스코프에 영향 줄 수 있음 | | `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 코어 구현 시점까지 미결 | @@ -55,7 +62,11 @@ | 문서 | 내용 | |---|---| -| `store-source-proxy-reversed.md` | 2026-08-04에 확정했던 `StoreSource` 프록시 설계(Store가 Source를 감춘 별도 프록시로 감쌈) — 2026-08-06 세 번째 세션에서 "Source가 State를 구조적으로 만족" 재구성으로 완전히 대체됨. 원문·역전 이유·신구 비교표 보존, `quadnomicon` 소재 후보 | +| `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/additional-primitives.md`)로 대체 | +| `context-rejected.md` | **[기각됨, 2026-08-07 신설]** `Context`(트리 하위 암묵 전파) + 대안이던 레이어드 Store 둘 다 기각 — 명시적 타입 강제 Store 전달로 충분하다는 판단 | ## 참고 diff --git a/.claude/archive/batch-rejected.md b/.claude/archive/batch-rejected.md new file mode 100644 index 0000000..52a52f5 --- /dev/null +++ b/.claude/archive/batch-rejected.md @@ -0,0 +1,63 @@ +# [기각됨] `Batch(fn)` — lexical block 기반 지연/합치기, `Blocker`로 대체됨 + +**기각 일시**: 2026-08-06~07. **현재 유효한 설계**: `base/ +additional-primitives.md`의 "Blocker" 절 — 이 문서가 다루는 것과 같은 +문제("여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게")를 +값 기반으로 풀어 대체함. 이 파일은 더 이상 능동적으로 참고할 필요 없음 +(구현에 안 씀) — "왜 lexical Batch를 기각하고 값 기반 Blocker를 택했는가"가 +`quadnomicon`(프레임워크 설계자용 심화 콘텐츠) 소재로 가치 있어서 사유를 +통째로 보존해둔 것. + +**중요**: 이건 "값 기반 지연/합치기"라는 문제 자체를 기각한 게 아니라, +**그 문제를 lexical block(Solid `batch()`/MobX `runInAction()`류)으로 +풀려는 접근만** 기각한 것 — 실제 해법은 완전히 다른 별개 primitive인 +`Blocker`로 채택됨. + +## 무엇을 검토했었나 + +`Batch(fn)`을 "플래그 세우고 `fn` 실행, 끝나면 flush"로 구현하는 안 — +`fn` 안에서 여러 `Set()`을 몰아서 호출해도 소비자에게 전파는 `fn`이 끝난 +뒤 딱 한 번만 되게 하는, 함수/코루틴 스코프 lexical transaction 블록. + +### "즉시 pull"이 뭔지 (참고용 예시) + +store-bind 핸들러(예: `Frame { BackgroundColor3 = total }`)는 lazy가 +아니다 — 화면에 실제로 반영하는 "누군가"가 바로 이 핸들러 자신이라, +무효화 신호를 받자마자 스스로 `Get()`하고 바로 대입한다: + +```lua +local total = a:With(b):Compute(function(av, bv) return av + bv end) +Frame { BackgroundColor3 = total } + +a:Set(1) -- total 무효화 → 핸들러가 즉시 (av=1, bv=이전b)로 재계산+대입 +b:Set(2) -- total 무효화 → 핸들러가 즉시 (av=1, bv=2)로 다시 재계산+대입 +-- BackgroundColor3가 중간값을 한 번 거쳐가고, 두 번 대입됨 +``` + +## 기각 이유 — 코루틴 yield 위에서 구조적으로 위험 + +`Batch(fn)`을 "플래그 세우고 `fn` 실행, 끝나면 flush"로 구현하면 **`fn`이 +yield하는 순간 위험해진다**(사용자 지적, 정확함): + +1. 플래그가 전역이면, yield 중 스케줄러가 돌리는 무관한 코루틴의 `Set()`이 + 이 Batch에 잘못 휘말릴 수 있음. +2. 플래그를 코루틴 스코프로 만들어도(Fusion `Contextual`류 코루틴 키 weak + table), `fn` 안에서 새 코루틴을 스폰하는 API(Promise, `task.spawn`)를 + 부르면 그 새 코루틴은 배치 스코프를 상속 못 받아 일부 Set이 새어나감. +3. `fn`이 영원히 재개 안 되면(장시간 대기, 리크된 코루틴) flush가 영영 안 + 일어나 store-bind 핸들러들이 화면을 무기한 stale 상태로 방치 — 즉시 + pull보다 더 나쁜 실패 모드. + +이건 구현을 잘 짜서 피할 수 있는 버그가 아니라 **lexical block 모델 +자체가 협조적 스케줄링 환경과 구조적으로 안 맞는 것**으로 판단, 기각. + +## 왜 완전히 헛수고는 아니었나 + +"지연 구간을 표현하고 싶다"는 문제의식 자체는 정확했고, `Blocker`가 +정확히 그 문제를 콜스택/코루틴이 아니라 **값**(`Blocker` 객체의 +`On()`/`Off()`)으로 표현해 풀었다 — Batch가 무너뜨렸던 세 가지 실패 +모드(전역 플래그 오염, 새 코루틴 미상속, 영구 yield로 인한 무기한 방치)가 +Blocker에선 구조적으로 전부 해당 안 됨(`On()`/`Off()` 사이에 얼마나 많은 +yield/코루틴 전환이 끼어도, 심지어 완전히 다른 코루틴에서 `Off()`를 +불러도 문제없음). `quadnomicon`에서 "콜스택/코루틴 스코프로 상태를 +표현하려던 시도가 왜 항상 위험한가"의 구체 사례로 쓰기 좋음. diff --git a/.claude/archive/context-rejected.md b/.claude/archive/context-rejected.md new file mode 100644 index 0000000..e39ece6 --- /dev/null +++ b/.claude/archive/context-rejected.md @@ -0,0 +1,61 @@ +# [기각됨] `Context`(트리 하위 암묵 전파) + 대안이었던 "레이어드 Store" + +**기각 일시**: 2026-08-06~07. **현재 유효한 설계**: 명시적 타입 강제 +Store 전달(`props.Theme: Store`처럼 컴포넌트가 필요한 걸 named +parameter로 명시적으로 요구) + 오버라이드가 필요한 지점에서 +`Store({...부모값, 변경필드=새값})`을 한 번 명시적으로 만들어 그 지점부터 +평소처럼 prop으로 넘기는 것 — 새 primitive 없이 이미 있는 Modifier의 +"merge, 나중 게 이김" 패턴 재사용. 이 파일은 더 이상 능동적으로 참고할 +필요 없음(구현에 안 씀) — "왜 Context가 없는가"가 `quadnomicon`(프레임워크 +설계자용 심화 콘텐츠) 소재로 가치 있어서 사유를 통째로 보존해둔 것. + +## 무엇을 검토했었나 + +React `Context`/Vue `provide`-`inject`류, 트리 상위에서 값을 하나 심어두면 +중간 컴포넌트가 명시적으로 전달하지 않아도 하위 어디서든 그 값을 읽을 수 +있는 암묵적 전파 메커니즘. + +### 난이도 판정 요약 + +서브에이전트 조사 결과: 동기적 저작 트리에 한정한 얕은 버전(Fusion +`Contextual`류 코루틴 키 weak table push-pop)은 구현 난이도 **낮음**이지만, +quad가 정상 패턴으로 확정한 "Slot에 이벤트 핸들러/코루틴에서 비동기로 +자식이 추가되는 경우"엔 **에러 없이 조용히 기본값으로 폴백**하는 함정이 +있음. 완전 자동(비동기 추가까지 자동 전파)은 Roblox Luau에 +thread-local/async-context-propagation 훅이 없어 **사실상 불가**(플랫폼 +한계, Node `AsyncLocalStorage`/Python `contextvars`도 "자동"이 아니라 +"async 경계마다 명시적 캡처+재진입"으로 같은 문제를 품). + +## 기각 이유 + +얕은 버전조차: (1) `base/slot-plan.md`가 정상 패턴으로 명시한 Slot 비동기 +추가에서 가장 먼저, 가장 조용히 깨짐. (2) `research/debug-tooling-plan.md`의 +"모든 연결은 선언된 그래프여야 한다"는 quad-debug 철학과 충돌하는 안 보이는 +채널을 만듦. + +## 대안이었던 "레이어드 Store"도 철회 (사용자 반박 수용) + +Context 대신 권고했던 대안 — "레이어드 Store"(자식 Source 모음이 없는 +키는 부모로 `__index` 폴백)도 사용자 반박으로 철회됨: + +- quad는 이미 **타입으로 강제되는 명시적 Store 전달**을 갖고 있고, 이건 + Context의 "Provider 안 넣으면 조용히 기본값/에러"보다 **더 안전**하다 + (컴파일타임에 걸림 vs 런타임에 조용히 새는 값) — Context보다 나은 지점. +- "필드 일부만 오버라이드, 나머지는 부모 값 그대로"가 필요하면, 오버라이드 + 지점에서 `Store({...부모값, 변경필드=새값})`을 **한 번 명시적으로** + 만들어서 그 지점부터 평소처럼 prop으로 넘기면 끝 — Modifier가 이미 쓰는 + "merge, 나중 게 이김" 패턴 재사용 가능, 새 primitive 불필요. 레이어드 + Store(읽는 시점에 몇 단계를 거슬러 올라가는지 안 보이는 자동 폴백)는 + 정확히 Context와 같은 이유(디버깅 어려움 — "이 값이 왜 이거지?"를 + 추적하려면 부모 체인을 다 훑어야 함)로 얻는 것보다 잃는 게 크다. +- 서드파티 라이브러리가 뭔가 필요하면, 타입이 강제하는 명시적 요구 + (`props.Theme: Store`)가 "몰래 안 줘서 죽는다"보다 나은 실패 + 모드 — Slot 기반 저작 모델(부모가 자손을 직접 구성)에서도 이런 요청은 + 자연스럽게 props로 흐른다. + +## 결론 + +Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Context가 +없는가"(명시적 Store 전달이 이미 그 역할을 하고, 타입 강제가 Context의 +실패 모드보다 안전하다는 논증)는 `quadnomicon` 에세이 후보로 등록 +(`research/documentation-content-map.md` 참고). diff --git a/.claude/archive/ui-shorthand-roundsize-dropped.md b/.claude/archive/ui-shorthand-roundsize-dropped.md new file mode 100644 index 0000000..cc87560 --- /dev/null +++ b/.claude/archive/ui-shorthand-roundsize-dropped.md @@ -0,0 +1,44 @@ +# [기각됨] `RoundSize`(이미지 9-slice 라운드 트릭) 포팅 — 네이티브 `UICorner`로 대체되어 불필요 + +**기각 일시**: 2026-08-06. **현재 유효한 설계**: `base/ui-shorthand-plan.md` — +이 문서는 v1의 `RoundSize`가 왜 포팅 대상에서 빠졌는지, 그리고 그 판단이 +한 차례 잘못 일반화됐다가 정정된 이력을 보존해둔 것. 능동적으로 참고할 +필요 없음(구현에 안 씀) — `RoundSize`류 "네이티브 Instance가 나중에 생겨 +워크어라운드가 필요 없어진 사례"는 `quadnomicon` 소재로 가치 있음. + +## 무엇이었나 + +v1 `class.lua`가 지원하던 특수 키 `RoundSize = 16`(`ImageLabel`/ +`ImageButton` 전용) — `UICorner`가 아니라 이미지 자체를 9-slice로 잘라 +둥글게 보이게 만드는 트릭(`round.SetRound()`). `UICorner`/`UIPadding`/ +`UIScale` 자동 생성 숏핸드(`Corner`/`PaddingAll`/`Scale`, 현재 +`base/ui-shorthand-plan.md`가 이어받은 기능)와 겉보기엔 "인라인 리터럴 값 +하나로 GUI를 꾸민다"는 카테고리가 비슷해 보이지만, **메커니즘 자체가 +완전히 다름**(하나는 별도 Instance 생성, 하나는 이미지 처리) — 이 문서가 +쓰인 이유가 바로 이 둘을 혼동하지 않기 위함. + +## 기각 이유 + +`RoundSize`는 **당시 Roblox에 `UICorner` 같은 네이티브 구현체가 없었기 +때문에** 존재하던 워크어라운드였음. 지금은 `UICorner`가 안정적인 네이티브 +Instance라 이미지 대상에도 그냥 실제 `UICorner`를 붙이면 되므로, 이미지를 +9-slice로 잘라 둥글게 "보이게" 만드는 트릭 자체를 그대로 포팅할 이유가 +없음 — **포팅 안 함으로 확정**. + +## 왜 archive에 남기나 — 한 차례 과잉일반화됐다가 정정된 이력 + +`RoundSize` 하나를 드롭하기로 한 판단이, 초안 작성 과정에서 실수로 +**"UICorner가 네이티브가 됐으니 Corner/PaddingAll/Scale 숏핸드 자체가 +불필요하다"는 훨씬 넓은 결론으로 잘못 일반화된 적이 있었음**("이전 정리 +('포팅 불필요')는 오해였고 정정함"). 사용자가 직접 반박해 정정됨: +`UICorner`가 네이티브 Instance가 됐다는 사실은 "이미지를 트릭으로 둥글게 +보이게 할 필요가 없어졌다"는 것만 의미할 뿐 — `UIScale`/`UIPadding`류가 +**여전히 부모에 Parent해야 하는 별도 Instance**라는 구조적 사실 자체는 +전혀 안 바뀌었으므로, `Corner`/`PaddingAll`/`Scale` 숏핸드(현재 +`UICorner`/`UIPadding`/`UIScale`)의 존재 이유는 그대로 유효. + +**교훈(재사용 가능)**: "네이티브 Instance가 생겼다"는 사실 하나로부터 +"관련 숏핸드 전체가 불필요해졌다"를 성급히 일반화하지 말 것 — 워크어라운드가 +드롭되는 이유(네이티브 대체재 등장)와 편의 숏핸드가 필요한 이유(별도 +Instance를 만들어 Parent해야 하는 구조적 번거로움)는 서로 다른 축이라, +하나가 해소됐다고 다른 하나도 자동으로 해소되는 게 아님. diff --git a/.claude/base/additional-primitives.md b/.claude/base/additional-primitives.md new file mode 100644 index 0000000..4c0d60d --- /dev/null +++ b/.claude/base/additional-primitives.md @@ -0,0 +1,157 @@ +# 추가 확정 프리미티브 — Blocker / Effect + +**상태**: base — `research/additional-primitives-plan.md`(다른 프레임워크 대비 +갭 분석)에서 갈라져 나온 두 확정 프리미티브. Batch(lexical block)/Context는 +기각되어 각각 `archive/batch-rejected.md`/`archive/context-rejected.md`로, +아직 미확정인 키 기반 동적 컬렉션 재조정은 `research/additional-primitives-plan.md`에 +그대로 남아있음 — 이 문서는 **확정된 것만** 다룬다. + +## Blocker — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게 + +**왜 필요한가**: `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`와 나란히 인용). + +--- + +## Effect — leaf 죽음에 확정 정리, 재실행 개념 없음 + +**Observer와는 별개의, 완전히 새로운 요소로 확정** — Ref/PreRef 같은 +서로 파생된 관계가 아니라 독립적으로 존재하는 primitive. Roblox엔 +`task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이 흔하고, Luau +테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는 순간"을 아는 +유일한 방법은 `Instance.Destroying`류 명시적 신호뿐 — 이런 케이스(타이머 +시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive로 합의됨. + +``` +Effect(fn) -> EffectHandle -- fn을 즉시 1회 실행, 리턴값(nil | () -> ())은 + -- 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출 +``` + +**재실행 개념이 없다** — 값 변화에 반응해 다시 도는 건 Observer(+클로저로 +직접 짠 cleanup)의 영역이고, Effect는 순수하게 "설치 + 확정 정리" 페어 +하나만 담당한다. children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 +그대로 재사용(그 leaf가 살아있는 동안만 유효, leaf가 죽으면 정리 콜백 +호출). 비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 +Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분. + +**Observer에 cleanup 반환 계약을 추가하는 안은 기각됨** — React `useEffect`류로 +`fn`이 `nil | () -> ()`를 반환하면 다음 재실행 직전에 그걸 불러주는 안을 +검토했으나, 클로저 업밸류로 이미 쉽게 되고 잘 작동해서(`local lastConn; +state:Observer(function() if lastConn then lastConn:Disconnect() end; +lastConn = ... end)`) 프레임워크가 이걸 대신해줄 이유가 약하다는 판단 — +`state:Observer(fn)` 자체는 여전히 재실행 계약만 갖고, Effect가 별도로 +"1회 설치 + 확정 정리"를 담당하는 이 분리 구조가 유지됨. + +### ⚠️ 미해결 — Effect와 Observer의 관계, 사용자 확인 필요 + +**임의로 결론내지 않고 열어둠(2026-08-07 문서 정리 세션)**: 위 스펙은 +`Effect(fn)`를 State에 종속되지 않는 완전한 자유 함수로 서술하지만, 아래 +두 가지가 문서상 명확히 확인되지 않음: + +1. **`Effect`가 실제로는 `state:Effect(fn)`처럼 State의 메소드(=Observer의 + 변형, "재실행 없음 + 확정 정리 추가"만 다른 버전)로 구현/노출되어야 + 하는 것 아닌가?** — 그렇다면 "독립 존재 가능한 프리미티브 vs 원천에 + 종속된 파생 데이터"(`base/store-semantics.md`) 분류상 Effect도 + Observer처럼 후자(자유 함수 생성자 없음, 항상 `:` 메소드)로 재분류해야 + 함. 지금 이 문서는 이전 조사(`research/additional-primitives-plan.md`)를 + 따라 자유 함수로 서술했지만, 이게 최종 확정인지는 불명확. +2. **`state:Observer(fn)`가 생성 시점에 `fn`을 즉시 1회 실행하는지가 문서 + 어디에도 명시돼 있지 않음** — `base/bind-system-plan.md`의 Observer + 절은 "값을 안 실어줌, `fn` 본문에서 `Get()`을 다시 읽어야 함"만 + 명시할 뿐 "생성 즉시 1회 호출되는지"는 다루지 않는다. Effect는 + "즉시 1회 실행"이 스펙에 명시돼 있어 이 부분만 보면 둘이 겹쳐 + 보인다. + +이 두 질문이 풀리면 Effect가 (a) 완전히 별개인 자유 함수 primitive로 +남는지, (b) `state:Effect(fn)`로 Observer 계열에 합류하는지가 갈린다. +**확인 전까지는 위에 적은 자유 함수 스펙을 잠정 스펙으로 두되, 구현 +착수(M3~M4 전후) 전에 반드시 사용자와 다시 확인할 것** — `.claude/ +question.md`에 같은 항목 등재됨. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 8e27a9d..5d6ba00 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -3,8 +3,8 @@ **상태**: base — 횡단 결정의 최종 상태 요약. 특정 기능 plan이 아니라 프로젝트 전체에 걸친 결정이라 완료 개념 없음. 근거가 된 원본 브레인스토밍은 `.claude/initreq/raw-userinput.md`(안 옮기고 그대로 둠 — 이 문서들로 나누기 전의 -raw chain-of-thought 백업 역할). 현재 v1 구조는 `base/quad-v1-architecture.md`, -비교 리서치는 `base/comparison-fusion-vide.md`, `base/lifecycle-pattern.md` 참고. +raw chain-of-thought 백업 역할). 현재 v1 구조는 `reference/quad-v1-architecture.md`, +비교 리서치는 `reference/comparison-fusion-vide.md`, `base/lifecycle-pattern.md` 참고. ## 한 줄 요약 @@ -23,7 +23,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 바인드처럼 정말 체인이 자연스러운 곳에만(`:` 문법) 남김. 타입 작성 난이도가 OOP 스타일에서 너무 커진다는 게 이유. 3. **복사(clone) 구현 지양, 팩토리 함수로 대체.** v1의 metatable 체이닝(1-필드 - 테이블을 계속 쌓는 방식, `base/quad-v1-architecture.md` 참고)은 폐기. + 테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기. store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. 4. **PA님 스타일 DI 키 계속 지원**: `[Attribute "Name"]`, `[Tag ""] = true` 같은 @@ -67,12 +67,12 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 구현(`base/bind-system-plan.md`). 9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher, 실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua`에 - 연결 안 된 죽은 코드였음, `base/quad-v1-architecture.md` 참고)은 렌더 + 연결 안 된 죽은 코드였음, `reference/quad-v1-architecture.md` 참고)은 렌더 라이브러리 범위 밖으로 판단. 스토리북 구현체(https://ui-labs.luau.page/docs/getstarted)가 이미 존재하므로 대체. 10. **lang 모듈 미구현, 분리.** 로케일/문자열 처리는 리액터블 라이브러리와 별개 라이브러리로 존재해야 함(v1 `lang.lua`의 전역 스코프 버그 등은 - `base/quad-v1-architecture.md` 참고 — 애초에 반면교사). + `reference/quad-v1-architecture.md` 참고 — 애초에 반면교사). 11. **커스텀 Signal 클래스 미구현.** 콜백 정도로 이벤트 바인드 뒤에 함수를 넣는 것만으로 충분하다고 판단. (이전 초안엔 "rbvm의 Signal이 재사용 가능해 보여 상충한다"는 메모가 있었으나 2026-08-04 검증 라운드에서 최종 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 59e30d8..1b524d1 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -8,9 +8,9 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 array API, `CreatedRef` 모양) 뿐 — 구현 단계에서 자연히 정리됨. 원본: `.claude/initreq/raw-userinput.md` "key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 -저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `base/quad-v1-architecture.md` +저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `reference/quad-v1-architecture.md` ("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 `.claude/initreq/tbox` -(레지스트리)와 Fusion/Vide 비교는 `base/comparison-fusion-vide.md` 참고. +(레지스트리)와 Fusion/Vide 비교는 `reference/comparison-fusion-vide.md` 참고. ## 문제 @@ -352,7 +352,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ## 이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06) **결정**: v1의 `function(self, ...)` 관습(`self`/`this`로 이벤트 대상 -Instance를 넘겨주는 것, `.claude/base/quad-v1-architecture.md` 참고 — +Instance를 넘겨주는 것, `.claude/reference/quad-v1-architecture.md` 참고 — 실제로 `event.lua`의 `Bind`가 `func(self or this, ...)`로 넘겨줌)은 **채택하지 않는다.** quad-roblox의 이벤트 핸들러는 엔진이 네이티브로 주는 이벤트 인자만 받는다(React의 `onXxx`가 DOM 노드가 아니라 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index e9d1c2c..bada990 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -10,7 +10,7 @@ Store/State/Source 온톨로지가 먼저 확정된 뒤에야 이 논의가 열 ## 문제 v1의 `Class.Extend()`(Init/Render/AfterRender/Getter/Setter/UpdateTriggers, -`base/quad-v1-architecture.md` 참고)는 이미 OOP 상속 스타일이라 폐기 +`reference/quad-v1-architecture.md` 참고)는 이미 OOP 상속 스타일이라 폐기 방향이지만, 그게 제공하던 실제 편의 기능(컴포넌트가 자기 store를 자동으로 가짐, props로 넘어온 State를 자동 흡수, `self:Default`/`self "key"`로 기본값·바인딩)까지 같이 버려도 되는지가 미결이었음. `MyComp {...}` 형태로 @@ -56,42 +56,30 @@ State는 `:With`/`:Compute`로 만들어진 파생값일 수 있어 쓰기가 의미 있음 — **사용자 확정**("맞음. 확실해"). 이 원칙 자체는 그대로 유지되고, 아래 3번의 구체적 메커니즘만 2026-08-06 후속 세션에서 더 단순하게 갱신됨. -### 3. [정정, 2026-08-06 후속 세션] `StoreSource` 프록시 개념 폐기 — Source가 State를 구조적으로 만족하므로 Store가 내부 Source를 그대로 반환 +### 3. Store는 내부 Source를 그대로 반환 — Source가 State를 구조적으로 만족 -**원래 이 절은 "Source를 인터페이스+구현체로 두고 Store 키에서 얇은 프록시 -(`StoreSource`)를 받는다"는 방향이었음 — 지금은 폐기됨.** 이후 세션에서 -Store/Source dot-access 타입 문제(레코드 타입의 읽기/쓰기 비대칭)를 -다루다가 더 근본적인 재구성으로 수렴: **`Source`가 구조적으로 -`State`를 만족**(단방향 호환, Svelte `Writable extends Readable`와 -같은 모양)하도록 만들면, Store가 "내부 Source를 감추고 별도 프록시를 -새로 만들어 노출"할 이유 자체가 없어짐 — `store.key`가 Store 생성 시 -이미 만들어둔 진짜 Source 객체를 그대로 돌려줘도 안전함(Source 자체가 -이미 State의 읽기 계약을 전부 만족하고, 거기에 `:Set(value)`/`:Emit()`이 -추가로 있을 뿐이라 "원본이라 쓰기 가능"이라는 위 2번 규칙과도 자연히 -맞아떨어짐). 상세 근거·타입 설계·Luau 솔버 검증 필요 항목은 -`base/store-semantics.md`의 "Source가 State를 만족함" 절이 최종 소스 — -이 문서는 배경만 유지. +**확정**: `Source`가 구조적으로 `State`를 만족하므로(단방향 호환, +Svelte `Writable extends Readable`와 같은 모양), `store.key`는 Store +생성 시 이미 만들어둔 진짜 Source 객체를 그대로 반환한다 — 별도 프록시 +타입도, 별도 캐싱 계층도 없음(Source 자체가 이미 State의 읽기 계약을 +전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을 뿐이라 "원본이라 +쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐). 쓰기 문법도 같이 +바뀜: `store.key = v`가 아니라 `store.key:Set(v)`(레코드 타입 읽기/쓰기 +대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 안 맞는다는 논거). 상세 +근거·타입 설계·Luau 솔버 검증 필요 항목은 `base/store-semantics.md`의 +"Source가 State를 만족함" 절이 최종 소스. -- **쓰기 문법도 같이 바뀜**: `store.key = v`가 아니라 `store.key:Set(v)` - (레코드 타입 읽기/쓰기 대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 - 안 맞는다는 논거, 같은 절 참고). -- **캐시 문제도 이걸로 자연히 해소**: State를 "매번 새로 만듦"이던 이전 - 모델과 달리, 이제 Store는 생성 시 만들어둔 Source를 그대로 갖고 있다가 - 돌려주기만 하므로 별도 캐싱 메커니즘 자체가 불필요(래퍼 생성 단계가 - 아예 없어짐 — 이전보다 더 쌈). +**[이전에 확정했다가 폐기된 `StoreSource` 프록시 설계는 이 결론으로 +완전히 대체됨 — 원문·역전 이유·신구 비교표는 +`archive/store-source-proxy-reversed.md` 참고, 여기서는 반복하지 않음.]** -### 4. [정정, 2026-08-06 후속 세션] Source 직접 전달 — 타입 유니온도 불필요해짐 +### 4. Source 직접 전달 — 타입 유니온 불필요, 서브타입 호환으로 자동 통과 -원래 "핸들러가 `Source | State` 유니온으로 받는다"는 방향이었으나, -Source가 State를 구조적으로 만족하는 지금은 **유니온 자체가 필요 없음** — -핸들러는 그냥 `State` 하나만 받아도 Source 인스턴스가 자동으로 그 -자리에 들어감(서브타입 호환). `isHandlable`/`priority`/`process`/`retract` -4종 계약에 5번째 항목을 추가할 필요 없다는 결론은 그대로 유지, 다만 근거가 -"타입 유니온으로 처리"에서 "서브타입이라 유니온 자체가 불필요"로 더 -단순해짐. 단, 핸들러가 "이거 Source면 역방향 쓰기까지 걸고 싶다"처럼 -**런타임에** Source인지 구분하고 싶은 경우는 여전히 있을 수 있음 — -그건 타입 유니온이 아니라 런타임 판별자(`isSource`류, `isObserver` -패턴과 동일한 결)로 처리하면 됨. +핸들러는 `State` 하나만 받아도 Source 인스턴스가 서브타입 호환으로 +자동 통과된다(`Source | State` 유니온 불필요, `isHandlable`/ +`priority`/`process`/`retract` 4종 계약에 5번째 항목 추가 불필요). 런타임에 +"이게 Source면 역방향 쓰기까지 걸고 싶다"처럼 구분하고 싶은 경우는 +`isSource`류 판별자로(`isObserver`와 동일한 패턴). - **실사용 범위가 좁다는 판단은 그대로 유지**: `isEnabled`처럼 여러 조건에 영향받는(=파생된) 값은 애초에 State지 Source가 아니므로 이 경로로 못 diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 6a5d18d..020495b 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -123,7 +123,7 @@ canExecute 같은 람다 함수 하나 달게 해서 Connected 상태 보게 하 즉, 핸들러가 처리 도중 만든 구독/옵저버 클로저는 그 자체로는 아무것도 자동으로 GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연결해 참조를 -붙잡아두던 "GC 방지 핫팩"(`base/quad-v1-architecture.md` 참고)과 같은 문제. +붙잡아두던 "GC 방지 핫팩"(`reference/quad-v1-architecture.md` 참고)과 같은 문제. **base가 범용 유틸로 제공할 것: 임의의 클로저/구독을 실제 Roblox 객체의 생명주기에 바인드하는 도구** — 내부적으로 v1/rbvm이 쓰던 "connect 트릭"(어떤 신호에든 연결해서 참조를 죽을 때까지만 붙잡아두는 것)을 그대로 재사용. 이 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 31d7133..476f8be 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -190,7 +190,7 @@ PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/bind-sys `Modifier.Rounded(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 `Corner` 특수 프로퍼티 선례, 핸들러 배치 소견)는 -`research/ui-shorthand-plan.md` 참고 — 이 문서는 Modifier 값 자체의 +`base/ui-shorthand-plan.md` 참고 — 이 문서는 Modifier 값 자체의 동작만 다루므로 분리. ### 6. State/Pipe 쪽엔 영향 없음 — 이미 있던 결정의 재확인일 뿐 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 56679c0..d3092fc 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -4,7 +4,7 @@ 소스 트리 상 패키지 경계까지 확정되어 `research/`에서 승격됨(`base/ architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 원본: `.claude/initreq/raw-userinput.md` "slot을 구현하도록 하기로 했음" 절. Fusion의 -`Children` SpecialKey와 Vide의 mount 무가드 비교는 `base/comparison-fusion-vide.md` +`Children` SpecialKey와 Vide의 mount 무가드 비교는 `reference/comparison-fusion-vide.md` 참고 — 결론: **두 라이브러리 어디에도 이런 엄격한 단일 마운트 가드가 없음, quad의 진짜 개선점.** @@ -34,7 +34,7 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 " Slot에 들어간 요소는 **ownership이 귀속**되며 다른 곳에 마운트할 수 없게 된다. `isMounted`를 관리해서, **한 인스턴스에 대한 다중 마운팅이 라이브러리 차원에서 절대 일어나지 않도록 강제**하는 게 v1 대비 핵심 디자인 변화. v1의 `mount()`는 -별다른 강제를 안 했지만(`base/quad-v1-architecture.md`의 mount.lua 분석 참고 — +별다른 강제를 안 했지만(`reference/quad-v1-architecture.md`의 mount.lua 분석 참고 — 실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 mount 함수 자체가 이 강제를 담당. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 18372c5..d048320 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -282,7 +282,7 @@ Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processo `useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 요구가 있었음(v1의 `myStore "a,b"` 콤마-조인 문자열 방식은 폐기 대상 — -`base/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). **v1의 +`reference/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). **v1의 `:Add`/`:With`/`:Tween`처럼 값을 직접 가공하는 이름 붙은(named) 체이닝 연산은 만들지 않음** — 대신 일반 함수를 받아 처리. (주의: 아래의 v2 `:With(...)`는 이름만 같을 뿐 v1의 `:With`와는 다른 연산임 — v1은 "함수/테이블에서 값을 @@ -291,3 +291,12 @@ Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processo `: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/additional-primitives.md`의 "Blocker" 절이 다룸(State 개발과 같은 +마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던 +초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — +`archive/batch-rejected.md` 참고. diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md new file mode 100644 index 0000000..390821b --- /dev/null +++ b/.claude/base/ui-shorthand-plan.md @@ -0,0 +1,108 @@ +# 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`가 +이미 예시로 든 `Modifier.Rounded(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-06 감사에서 지적된 항목, 여기서 같이 +확정)**: 재사용 대상은 quad가 이전에 만든 고정 이름(`_quad_corner`류) +자식으로 한정 — 타입만 보고(`UICorner`이기만 하면 아무거나) 재사용하지 +않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을 +피하기 위함. + +## store-bind — 이 숏핸드도 지원, Tween만큼 무겁게 안 가도 됨 + +v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했음 +(`myStore "key"` 체이닝으로 다른 프로퍼티와 동일하게 취급됨) — quad-v2도 +이 능력을 유지한다. 트윈처럼 애니메이션까지 지원할 필요는 없음(API 표면만 +복잡해짐) — 그냥 값이 바뀌면 `CornerRadius`/`Padding`/`Scale` 프로퍼티를 +다시 세팅하는 정도로 충분. 구현 비용도 낮음: 각 Handler가 `process`에서 +"이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미 +base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소 +(`base.perInstanceState(inst)`, `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와 +함께 구현하면 충분. + +## 남은 열린 질문 (단순화 후보, 사소함) + +- Corner/PaddingAll/Scale 3개 거의 동일한 형태의 Handler를 각각 만들지, + `{key -> {ChildClassName, ChildDefaultName, Property, wrap=fn}}` 룩업 + 테이블로 구동되는 단일 `Handlers/InstanceShorthand.luau`로 통합할지 — + `research/pre-implementation-audit.md` 3-2번 참고, 강제 사항 아님, + 구현 시점에 결정할 정도의 사소한 개선 후보. diff --git a/.claude/question.md b/.claude/question.md index 61fbcd8..2c54ff4 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -14,8 +14,11 @@ 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 여러 서브에이전트 -조사 + 사용자와 라이브 논의로 계속 수렴 중, 상세는 `research/ -additional-primitives-plan.md`. 요지: +조사 + 사용자와 라이브 논의로 계속 수렴 중. **2026-08-07 문서 정리에서 +확정/기각된 항목은 `research/additional-primitives-plan.md`에서 +분리됨**: Effect/Blocker → `base/additional-primitives.md`, Batch → +`archive/batch-rejected.md`, Context(+레이어드 Store) → `archive/ +context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 남김. - **키 기반 동적 컬렉션 재조정(유일하게 아직 완전히 열려있음, 최우선)**: Fusion `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, @@ -26,33 +29,20 @@ additional-primitives-plan.md`. 요지: 못 쓴다는 반례로 철회됨), Slot에 파괴 없이 빼내는 `Extract` 연산 추가 필요 — 최종 이름만 미정(아래 "용어 정리" 절에 후보 추가). **사용자가 "작업 전에 모든 정의를 마치고 싶다"고 명시** — M0 이전 완전 확정 목표. -- **Effect — 거의 수렴**: leaf가 죽을 때 확정적으로 정리 콜백을 부르는 - 단순 primitive(재실행 개념 없음, Observer와 별개)로 합의, 시그니처만 - 남음. Observer에 cleanup 반환 계약을 얹는 안은 기각(클로저 업밸류로 - 이미 충분 — `pre-implementation-audit.md` 3-1과 같은 논리). -- **Blocker — 채택, 핵심 메커니즘+이름 확정(2026-08-07 신설)**: 여러 - Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 - primitive — lexical Batch(아래 항목, 기각)와 달리 콜스택/코루틴이 아니라 - **값**(`Blocker` 객체의 `On()`/`Off()`)으로 지연 구간을 표현해 코루틴 - yield 위험을 구조적으로 우회함. `state:Block(blocker) -> state`가 - gated state를 반환(호출 즉시 onunblock 핸들 등록), `IsBlocked`/ - `HasBlockedEmit` 필드, 재진입(네스팅)은 의도적으로 미지원(Rust - poisoned-mutex류 위험 회피 — 겹치는 배치는 각자 새 `Blocker`를 쓸 것, - **문서화에서 강하게 명시 필요**). `base/`로 승격 가능한 수준, 남은 건 - 문서화뿐. -- **Batch(함수/코루틴 스코프 lexical block) — 기각 확정**: lexical - transaction 블록이 코루틴 yield 위에서 구조적으로 위험(전역/코루틴 - 스코프 플래그 둘 다 새 코루틴 스폰이나 영구 yield에 깨짐) — Blocker로 - 대체됐으므로 더 이상 미해결 문제 아님, quadnomicon "왜 Batch 대신 - Blocker인가" 에세이 대상. -- **Context — 기각 확정, 대안이던 레이어드 Store도 철회**: 완전 자동 - 버전은 Roblox Luau 플랫폼 한계로 사실상 불가, 얕은 버전도 Slot 비동기 - 추가에서 조용히 깨짐 + quad-debug 철학과 충돌. 레이어드 Store 대안은 - 사용자 반박으로 철회 — 이미 있는 타입 강제 명시적 Store 전달 + - 오버라이드 지점에서 새 Store를 만들어 넘기는 것으로 충분하다는 판단. - 둘 다 "왜 없는가" quadnomicon 에세이 대상. + 상세는 `research/additional-primitives-plan.md`(이제 이 주제 전용). +- **Effect가 Observer의 변형(`state:Effect()`)인지, 완전히 독립된 free + function인지 — 신규, 2026-08-07 문서 정리 세션에서 발견.** `base/ + additional-primitives.md`가 지금까지의 조사대로 Effect를 "재실행 없는 + 독립 free function"으로 서술해뒀지만, 사용자가 직접 `state:Effect()` + 형태(=Observer에 "확정 정리" 계약만 추가된 변형)로 기억하고 있어서 + 확인이 필요함. 관련 하위 질문: `state:Observer(fn)`가 생성 시점에 + `fn`을 즉시 1회 실행하는지도 현재 문서 어디에도 명시돼 있지 않음(Effect는 + "즉시 1회 실행"이 스펙에 있음 — 이 부분만 보면 둘이 겹쳐 보이는 이유). + **임의로 결론내지 않고 열어둠** — 구현 착수(M3~M4 전후) 전에 확인 필요, + 상세는 `base/additional-primitives.md`의 "미해결" 절. - Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 - 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단. + 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단(`research/ + additional-primitives-plan.md` "빈 자리 아닌 것" 절). ### 1. 용어 정리 (사용자 요청, 진행 중) @@ -95,7 +85,8 @@ additional-primitives-plan.md`. 요지: 사용자 피드백으로 탈락. 후보: `Render`(가장 직접적이지만 "quad엔 렌더 주기가 없다"는 기존 원칙과 이름이 충돌해 보일 수 있음), `Draw`(짧지만 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 - 미정, `research/additional-primitives-plan.md` 1번 절 참고. + 미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션 + 재조정" 절 참고. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ @@ -131,9 +122,10 @@ additional-primitives-plan.md`. 요지: - **`canExecute`/`Connected`의 실제 구현 방식(Parent==nil vs Connection. Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6. -- **`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 - 필요로 함(로드맵 순서 역전)** — quad-base 인터페이스 정의를 M2/M3로 - 옮기는 게 자연스러워 보임, `ROADMAP.md` 수정 필요 — 우선순위1-9. +- **~~`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 + 필요로 함(로드맵 순서 역전)~~ — 반영 완료(2026-08-07 세 번째 세션)**: + `LifetimeHandle`/`PerInstanceState` 인터페이스(타입만)를 `ROADMAP.md` + M2로 옮기고, quad-roblox 실 구현만 M8에 남김 — 우선순위1-9 해소. - 그 외(Slot CRUD 의미론 미정의, retract 시 "이전 핸들러" 추적 책임 소재, 우선순위 스캔 동률/매치실패 처리, `:Compute`의 `previous` 인자가 오버엔지니어링일 수 있음, UI shorthand의 기존 UICorner 매칭 기준 등)는 @@ -141,10 +133,19 @@ additional-primitives-plan.md`. 요지: ### 3. 낮은 우선순위 +- **`None`(가칭) 센티널 프리미티브 — 미확정, 2026-08-07 세 번째 세션 + 신설.** `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 + 명시적으로 지우고 싶어도 Lua 테이블 리터럴의 `키 = nil`은 키가 아예 + 없는 것과 구별이 안 돼서 "인라인이 modifier보다 무조건 우선"이라는 + 기존 merge 규칙(`modifier-plan.md` 2번)이 이 케이스에선 실제로 작동을 + 안 함. `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 실재하는 값 + `None`을 도입하는 방향만 나왔고 상세(타입, flatten 내부 표현, State + 필드에도 같은 문제가 적용되는지)는 미정 — `modifier-plan.md` "2-1"절 + 참고, M7(Modifier) 착수 전 확인. - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 착수를 막지 않음. - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — - `base/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 + `reference/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록. - **여러 Slot이 형제로 섞일 때 순서 보장** — `base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절 참고. Roblox 단일 백엔드로는 급하지 않음 @@ -172,10 +173,6 @@ additional-primitives-plan.md`. 요지: 중 뭘로 갈지 — 소견은 DI 인스턴스 생성 패턴처럼 "제네릭 하나 + 자주 쓰는 타입만 정적 지름길" 절충이지만 확정 아님, M10(Handlers/Attribute) 착수 전 아무 때나 확인해도 됨. -- **UICorner/UIPadding/UIScale 인라인 편의 키 세부** — `research/ - ui-shorthand-plan.md`. 기능 필요 여부(여전히 필요로 재확정)·메커니즘 - (Handler)·패키지 배치(quad-roblox 코어)는 확정, 남은 건 이름 재검토 - (용어 정리 합류)와 `RoundSize`(이미지 라운드) 대체 방식뿐. - **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`** — `research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴). 방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy @@ -203,8 +200,11 @@ additional-primitives-plan.md`. 요지: | Modifier(정적 merge, immutable 체이닝, State 필드 지원) | `base/modifier-plan.md` | | 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Merge`) | `base/component-composition-plan.md` | | 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | -| Fusion/Vide 비교 리서치(주의: 일부 서술은 이후 라운드에서 뒤집힘, 문서 내 정정 표시 참고) | `base/comparison-fusion-vide.md` | -| v1 내부 동작 스냅샷 | `base/quad-v1-architecture.md` | +| Blocker(값 기반 emit 지연/합치기), Effect(leaf 죽음에 확정 정리 — 단 Observer와의 관계는 위 0번 열린 질문 참고) | `base/additional-primitives.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` | | 트윈 오버라이드(기본값 Cancel), 세부 옵션만 남음 | `research/tween-plan.md` | | quad2-try(폐기된 이전 시도) 리서치 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW 전부 죽은 접근으로 확인, 반복 조사 금지 | `base/bind-system-plan.md` | diff --git a/.claude/base/comparison-fusion-vide.md b/.claude/reference/comparison-fusion-vide.md similarity index 92% rename from .claude/base/comparison-fusion-vide.md rename to .claude/reference/comparison-fusion-vide.md index 613bff5..63248ee 100644 --- a/.claude/base/comparison-fusion-vide.md +++ b/.claude/reference/comparison-fusion-vide.md @@ -1,7 +1,11 @@ # Fusion / Vide 아키텍처 비교 — quad-v2 설계 근거 -**상태**: base — 리서치 스냅샷(참고용 근거 자료), "완료" 개념 없음. quad-v2의 -Store/Slot/Tween/bind-dispatch 설계 결정에 인용되는 원본 비교 자료. +**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. **[2026-08-07 +문서 정리에서 `base/`→`reference/`로 이동]** quad에 관한 결정 자체가 아니라 +Fusion/Vide 리서치 스냅샷이라 항상 읽어야 하는 base 컨텍스트는 아님 — +`quadnomicon`(프레임워크 설계자용 심화 콘텐츠) 소재 후보이기도 함. quad-v2의 +Store/Slot/Tween/bind-dispatch 설계 결정에 근거로 인용될 때만 열어볼 것, +실제 확정 사항은 인용하는 쪽 `base/` 문서가 소스. ## Fusion (`.claude/initreq/fusion/`) diff --git a/.claude/base/quad-v1-architecture.md b/.claude/reference/quad-v1-architecture.md similarity index 88% rename from .claude/base/quad-v1-architecture.md rename to .claude/reference/quad-v1-architecture.md index bd14cb8..249cbdf 100644 --- a/.claude/base/quad-v1-architecture.md +++ b/.claude/reference/quad-v1-architecture.md @@ -1,9 +1,13 @@ # quad v1 내부 구조 (재작성 이전 기준선) -**상태**: base — 참고용 스냅샷, "완료" 개념 없음. v1(`.claude/initreq/quad/`)이 -실제로 어떻게 동작하는지 정리한 문서로, v2 설계 시 "이 문제를 안 반복하려면"의 -기준선으로 계속 참조됨. 아래는 리서치 에이전트가 file:line까지 확인한 내용의 요약 — -정확한 인용이 필요하면 `.claude/initreq/quad/src/*.lua` 원본을 볼 것. +**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. **[2026-08-07 +문서 정리에서 `base/`→`reference/`로 이동]** v1 자체에 대한 스냅샷일 뿐 v2의 +결정 사항이 아니라서 항상 읽어야 하는 base 컨텍스트는 아님 — 다른 문서가 +"v1은 이랬는데"를 인용할 때만 열어볼 것. v2 설계 시 "이 문제를 안 반복하려면"의 +기준선으로 근거 인용되는 용도는 그대로 유지(각 인용 지점은 여전히 +`base/`에 있음, 이 문서는 그 인용의 원본 소스). 아래는 리서치 에이전트가 +file:line까지 확인한 내용의 요약 — 정확한 인용이 필요하면 +`.claude/initreq/quad/src/*.lua` 원본을 볼 것. ## 공개 API 개요 diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 636b420..0a53e2f 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -1,10 +1,12 @@ # 추가 프리미티브 필요성 조사 — 다른 프레임워크 대비 갭 분석 -**상태**: research — 사용자와 라이브 논의로 수렴(2026-08-06~07). Context/ -Batch(lexical)는 **기각 확정**. **Blocker**(새 primitive, Batch의 대안으로 -채택)는 핵심 메커니즘+이름 확정, 문서화만 남음. 키 기반 동적 컬렉션 -재조정은 설계 진행 중(사용자가 "작업 전에 모든 정의를 마치고 싶다"고 -명시 — M0 전 완전 확정이 목표). Effect는 거의 수렴, 시그니처만 남음. +**상태**: research — 사용자와 라이브 논의로 대부분 수렴(2026-08-06~07), +**2026-08-07 문서 정리에서 확정/기각된 항목을 분리**: Effect/Blocker → +`base/additional-primitives.md`, Batch(lexical) → `archive/ +batch-rejected.md`, Context(+레이어드 Store 대안) → `archive/ +context-rejected.md`. 이 문서에는 **아직 완전히 열려있는 것 하나만** 남음 +— 키 기반 동적 컬렉션 재조정. 사용자가 "작업 전에 모든 정의를 마치고 +싶다"고 명시 — M0 전 완전 확정이 목표. ## 배경 @@ -25,20 +27,20 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 ## 결론 요약 -| 후보 | 판정 | 상태 | +| 후보 | 판정 | 현재 위치 | |---|---|---| -| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** | 설계 진행 중 | -| Effect(leaf 죽음에 확정 정리) | 진짜 빈 자리 | 거의 수렴, 시그니처만 남음 | -| **Blocker(값 기반 emit 지연/합치기)** | **채택** — Batch의 대안 | 핵심 메커니즘+이름 확정, 문서화만 남음 | -| Batch(함수/코루틴 스코프 lexical block) | **기각** | 결정 완료 | -| Context(트리 하위 암묵 전파) | **기각** — 대안(레이어드 Store)도 철회 | 결정 완료 | -| Observer에 cleanup 반환 계약 추가 | **기각** — 클로저 업밸류로 이미 충분 | 결정 완료 | -| Untrack/Peek | 빈 자리 아님 | - | -| Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | - | -| Error Boundary | 빈 자리 아님 | - | -| Readonly wrapper | 빈 자리 아님 | - | +| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** — 아직 열려있음 | 이 문서(아래) | +| Effect(leaf 죽음에 확정 정리) | 채택, 단 Observer와의 관계는 미해결 | `base/additional-primitives.md` | +| Blocker(값 기반 emit 지연/합치기) | **채택** — Batch의 대안 | `base/additional-primitives.md` | +| Batch(함수/코루틴 스코프 lexical block) | **기각** | `archive/batch-rejected.md` | +| Context(트리 하위 암묵 전파) + 레이어드 Store | **기각** | `archive/context-rejected.md` | +| Observer에 cleanup 반환 계약 추가 | **기각** — 클로저 업밸류로 이미 충분 | `base/additional-primitives.md`(Effect 절에 근거만 인용) | +| Untrack/Peek | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | +| Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | 아래 "빈 자리 아닌 것" 절 | +| Error Boundary | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | +| Readonly wrapper | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | -## 1. 키 기반 동적 컬렉션 재조정 — 최우선, 설계 진행 중 +## 키 기반 동적 컬렉션 재조정 — 최우선, 설계 진행 중 **무엇인가**: 데이터 배열(인벤토리, 리더보드, 채팅로그처럼 삽입/삭제/ 재정렬되는 목록)을 UI로 렌더링할 때, 이전 렌더 결과와 새 데이터를 @@ -157,231 +159,6 @@ Slot:Add(element, index?) -- 기존 그대로, Extract로 뺀 것도 다시 먼저 정하고 나중에 여기를 끼워맞추면 재작업이 날 가능성이 높음. Slot CRUD 시맨틱을 정의할 때 이 프리미티브의 요구사항을 같이 고려할 것. -## 2. Effect — 거의 수렴, 시그니처만 남음 - -### Observer에 cleanup 반환 계약을 추가하는 안 — 기각 - -React `useEffect`류처럼 "`fn`이 `nil | () -> ()`를 반환하면 다음 재실행 -직전에 그걸 불러준다"를 `state:Observer(fn)`에 얹는 안을 검토했으나 -**사용자가 기각** — 클로저 업밸류로 이미 쉽게 되고 잘 작동하는데 -(`local lastConn; state:Observer(function() if lastConn then -lastConn:Disconnect() end; lastConn = ... end)`), 프레임워크가 이걸 -대신해줄 이유가 약하다는 판단. 이건 `pre-implementation-audit.md` -3-1번("`:Compute(fn)`의 `previous` 인자 — 클로저 업밸류로 이미 되는 걸 -별도 API로 만든 것일 수 있음")과 **정확히 같은 논리** — 일관성 있는 -판단으로 보임(3-1 자체도 같은 이유로 재검토 대상일 수 있음, 별도 항목). - -### Effect — 별도의 단순한 primitive로, "leaf 죽음에 확정 정리"만 담당 - -Roblox엔 `task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이 흔하고, -Luau 테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는 순간"을 -아는 유일한 방법은 `Instance.Destroying`류 명시적 신호뿐이다. 이런 -케이스(타이머 시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive는 -필요하다는 데 합의: - -``` -Effect(fn) -> EffectHandle -- fn을 즉시 1회 실행, 리턴값(nil | () -> ())은 - -- 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출 -``` - -Observer와 달리 **재실행 개념이 없다** — 값 변화에 반응해 다시 도는 건 -Observer(+클로저로 직접 짠 cleanup)의 영역이고, Effect는 순수하게 "설치 + -확정 정리" 페어 하나만 담당한다. children 배열에 leaf로 놓는 기존 Observer -바인딩 패턴을 그대로 재사용(그 leaf가 살아있는 동안만 유효, leaf가 죽으면 -정리 콜백 호출). 비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak -table로 되는 Observer보다 비쌈) — 사용자가 필요할 때만 쓰는 걸로 충분. - -**상태**: 사실상 수렴 — 시그니처(`fn`의 인자 유무, `EffectHandle`의 모양)만 -다듬으면 `base/`로 승격 가능해 보임. 계속 논의 원하면 이어감. - -## 3. Batch(함수/코루틴 스코프 lexical block) — 기각 확정 - -**중요**: 아래는 "값 기반 지연/합치기"라는 문제 자체를 기각한 게 아니라, -**그 문제를 lexical block(Solid `batch()`/MobX `runInAction()`류)으로 -풀려는 접근만** 기각한 것이다. 실제 해법은 완전히 다른 별개 primitive인 -**Blocker**(아래 3-1번)로 채택됨 — 이 절은 "왜 lexical 접근은 안 되는가"만 -다루는 순수 반면교사 기록으로 남긴다. - -### "즉시 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가 중간값을 한 번 거쳐가고, 두 번 대입됨 -``` - -### 왜 lexical block 방식을 안 쓰는가 - -`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 모델 -자체가 협조적 스케줄링 환경과 구조적으로 안 맞는 것**으로 판단, 기각. - -## 3-1. Blocker — 채택된 새 primitive (Batch의 대안, 2026-08-06~07 세션) - -### 핵심 아이디어 — 콜스택/코루틴이 아니라 값으로 지연 구간을 표현 - -Batch가 실패하는 근본 이유는 "지연 구간"을 콜스택/코루틴 스코프로 -표현하려 했기 때문이다. `Blocker`는 그 구간을 **그냥 사용자가 들고 있는 -값**으로 표현한다 — `On()`/`Off()`를 부르는 두 시점 사이에 얼마나 많은 -yield/코루틴 전환이 끼어도, 심지어 완전히 다른 코루틴에서 `Off()`를 -불러도 아무 문제가 없다. Batch를 무너뜨렸던 세 가지 실패 모드(전역 플래그 -오염, 새 코루틴 미상속, 영구 yield로 인한 무기한 방치)가 구조적으로 전부 -해당 안 됨. - -### 메커니즘 (확정) - -``` -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면 - 아무 것도 안 함(이미 언블록 상태에서 다시 Off를 불러도 안전 — - idempotent). - -**`:Get()`엔 영향 없음** — 블록은 emit **전파**(eager 소비자에게 "바뀌었다" -알리는 신호)만 지연시킨다. 블록 중이라도 누군가 명시적으로 `:Get()`하면 -그 순간의 실제 값을 정상적으로 계산해서 준다 — `store-semantics.md`의 -"Get()은 라이브 레퍼런스를 준다" 원칙과 일치. - -### 사용 예시 — `state1, state2 -> state3` 케이스의 정답 - -처음 문제 제기("state1, state2 -> state3로 갈 때 둘 다 업데이트하면 state3가 -두 번 계산됨, 한번에 할 방법이 없다")에 대한 답: **`state3`(결합된 결과) -하나에만 `:Block`을 걸면 된다** — `state1`/`state2` 각각에 걸 필요 없음: - -```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를 이 - 블로커에 배선한다)가 같은 단어로 다른 두 동작을 가리키게 됨 — 자체 - API 안에서 `register`→`State` 리네임 때 겪었던 "모호함은 풀었는데 - 충돌이 새로 생긴" 패턴이 반복될 뻔한 걸 미리 피함. -- 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태) — `Enabled`보다 - 명확(enabled는 "정상 작동 중"으로도 읽혀 헷갈릴 수 있음). -- 필드: **`HasBlockedEmit`**(gated state의 대기 플래그) — `Is`/`Has` - 접두어로 불리언임을 바로 알려줌. -- 메소드: `state:Block(blocker) -> state`. - -### 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 - -`IsBlocked`는 카운터가 아니라 단순 불리언이고, **의도적으로 그렇게 -둔다.** 레퍼런스 카운팅으로 네스팅을 지원할 수도 있었지만, 그러면 -"`On()` 여러 번, `Off()` 실수로 적게" 같은 버그가 **영구 블록으로 조용히 -새는** 더 위험한 실패 모드를 만든다. 언어 차원에서 "On~Off 사이 코드가 -죽었는지, 스레드가 죽었는지"를 추적하는 것도 해키해서 하지 않기로 함(Rust의 -"poisoned mutex" — 락 구간 안에서 패닉이 나면 락이 오염 상태가 되는 것과 -유사한 문제의식, 그 트래킹 자체를 만들지 않기로 함). - -**대신 확정된 규칙**: 겹치는 배치가 필요하면 **각자 새 `Blocker` 인스턴스를 -만들 것** — 하나의 Blocker를 여러 컨텍스트에서 재사용/중첩하지 않는다. -`Off()`는 스태킹 없이 즉시 그 자리에서 꺼진다. **이 제약은 반드시 사용자 -문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 -조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. - -### 상태: 핵심 메커니즘+이름 확정. 남은 건 문서화뿐 - -`Extract`/`Add`처럼 세부 시그니처가 더 필요한 다른 항목들과 달리, Blocker는 -설계 질문이 남아있지 않음 — `base/`로 승격 가능한 수준. quadnomicon에서 -"Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 게 좋은 소재(아래 -"문서화 백로그" 참고). - -## 4. Context — 기각 확정, 레이어드 Store 대안도 철회 (결정 완료) - -### 난이도 판정 요약 - -서브에이전트 조사 결과: 동기적 저작 트리에 한정한 얕은 버전(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 대안도 철회 (사용자 반박 수용) - -이전 라운드에서 대안으로 "레이어드 Store"(자식 Source 모음이 없는 키는 -부모로 `__index` 폴백)를 권고했는데, 사용자 반박으로 철회함: - -- quad는 이미 **타입으로 강제되는 명시적 Store 전달**을 갖고 있고, 이건 - Context의 "Provider 안 넣으면 조용히 기본값/에러"보다 **더 안전**하다 - (컴파일타임에 걸림 vs 런타임에 조용히 새는 값) — Context보다 나은 지점. -- "필드 일부만 오버라이드, 나머지는 부모 값 그대로"가 필요하면, 오버라이드 - 지점에서 `Store({...부모값, 변경필드=새값})`을 **한 번 명시적으로** - 만들어서 그 지점부터 평소처럼 prop으로 넘기면 끝 — Modifier가 이미 쓰는 - "merge, 나중 게 이김" 패턴 재사용 가능, 새 primitive 불필요. 레이어드 - Store(읽는 시점에 몇 단계를 거슬러 올라가는지 안 보이는 자동 폴백)는 - 정확히 Context와 같은 이유(디버깅 어려움 — "이 값이 왜 이거지?"를 - 추적하려면 부모 체인을 다 훑어야 함)로 얻는 것보다 잃는 게 크다. -- 서드파티 라이브러리가 뭔가 필요하면, 타입이 강제하는 명시적 요구 - (`props.Theme: Store`)가 "몰래 안 줘서 죽는다"보다 나은 실패 - 모드 — Slot 기반 저작 모델(부모가 자손을 직접 구성)에서도 이런 요청은 - 자연스럽게 props로 흐름. - -### 결론 - -Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Context가 -없는가"(명시적 Store 전달이 이미 그 역할을 하고, 타입 강제가 Context의 -실패 모드보다 안전하다는 논증)도 quadnomicon 에세이 후보로 등록(아래 -"문서화 백로그" 참고). - ## 빈 자리 아닌 것으로 확인된 것들 - **Untrack/Peek**(Solid `untrack()`, Vue `toRaw`): quad는 Vide식 암묵 @@ -411,9 +188,10 @@ Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Co ## 문서화 백로그 (2026-08-06~07, `documentation-content-map.md`에도 반영) - **quadnomicon 에세이**: - - "왜 lexical Batch를 기각하고 대신 값 기반 Blocker를 택했는가" — 코루틴 - yield 위험 분석(Batch 절)과 Blocker의 설계(3-1절)를 나란히 비교. - - "왜 Context가 없는가"(명시적 타입 강제 Store 전달이 이미 그 역할을 함). + - "왜 lexical Batch를 기각하고 대신 값 기반 Blocker를 택했는가" — + `archive/batch-rejected.md`와 `base/additional-primitives.md`의 + Blocker 절을 나란히 비교. + - "왜 Context가 없는가" — `archive/context-rejected.md` 참고. - "왜 push-invalidate/pull-recompute 설계가 laziness와 재계산 방지를 최우선 목표로 뒀는가" — Blocker 같은 파생 프리미티브가 이 목표 위에서 자연스럽게 나온 이유까지 포함해 기존 심화 콘텐츠 후보 3번(`왜 @@ -426,22 +204,12 @@ Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Co 유연한 구조" — 명시적 의존성 선언(`:With`) 위에서도 실제 계산은 조건부로 일부만 쓸 수 있다는 팁. - "Blocker 사용 가이드" — 파이프라인 최종 연산 지점에 배치, **네스팅 - 금지를 강하게 명시**(위 3-1절 "재진입" 참고, 문서화 시 최우선 강조 - 항목). + 금지를 강하게 명시**(`base/additional-primitives.md`의 "재진입" 절 + 참고, 문서화 시 최우선 강조 항목). - "여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산을 피하는 파이프라인/업데이트 순서 팁"(Blocker를 안 쓰는 단순 케이스용 보조 팁, 기존 "심화 최적화 팁" 항목을 Blocker 존재를 전제로 재조정). -## 제안 우선순위 - -1. **키 기반 컬렉션 재조정** — 여전히 최우선, 설계 진행 중. M0 이전 완전 - 확정이 목표. -2. **Effect** — 거의 수렴, 시그니처만 다듬으면 됨. -3. **Blocker** — 핵심 메커니즘+이름 확정, `base/`로 승격 가능한 수준. - 문서화(특히 네스팅 금지 강조)만 남음. -4. **Batch(lexical)/Context** — 둘 다 결정 완료(기각), 더 이상 검토 대상 - 아님. - ## 참고: 조사에 사용한 소스 근거 - Fusion: `State/ForPairs.luau`, `State/ForKeys.luau`, diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index e56d26e..7946195 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -328,7 +328,7 @@ UI를 클릭한 순간)에 강제로 그 계산을 트리거하는 건 이 전 **네이밍 컨벤션(사용자 제안)**: 내부 자동 생성 helper Instance는 `_`나 `QUAD_` 같은 접두어를 붙여서 Explorer에서 직접 봤을 때 헷갈리지 않게 함 — 이름 바꾸는 건 비용이 크지 않음. v1이 이미 `_quad_round`/`_quad_padding`/ -`_quad_scale` 네이밍(`research/ui-shorthand-plan.md` 참고)으로 정확히 +`_quad_scale` 네이밍(`base/ui-shorthand-plan.md` 참고)으로 정확히 이 관습을 썼던 전례 — quad-v2에서 내부 자동 생성물이 생기면 그대로 재사용. `research/documentation-plan.md`의 "UI 네이밍 컨벤션 문서" 백로그에도 이 구체적 규칙을 추가해둠. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 4706b53..a5f651b 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -37,7 +37,7 @@ 11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `CreatedRef(fn)` + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `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 한정)** — `Corner`/`PaddingAllOffset`/`Scale` 인라인 키 (`research/ui-shorthand-plan.md`) +14. **UI 숏핸드(quad-roblox 한정)** — `UICorner`/`UIPadding`/`UIPaddingOffset`/`UIScale` 인라인 키 (`base/ui-shorthand-plan.md`) --- @@ -135,7 +135,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 실제 일어나는 derived state)에 배치하는 게 원칙이라는 것, **네스팅 금지를 최우선으로 강조**(겹치는 배치는 각자 새 `Blocker`를 만들 것 — 안 지키면 조용히 잘못된 시점에 조기 해제되는 원인 추적 어려운 버그로 - 이어짐) — `research/additional-primitives-plan.md` 3-1절 + 이어짐) — `base/additional-primitives.md`의 "Blocker" 절 19. 여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산/재대입을 피하는 파이프라인/업데이트 순서 팁(Blocker를 안 쓰는 단순 케이스용 보조 팁) — `research/additional-primitives-plan.md` "문서화 백로그" 절 @@ -197,13 +197,15 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: ## 5. 문서화 아직 보류(미확정 설계라 쓰면 안 됨) - Slot 형제 순서 보장 (`slot-plan.md`) -- Tween 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름 (`research/tween-plan.md`) -- UI 숏핸드 `RoundSize` 드롭 여부 (`research/ui-shorthand-plan.md`) +- Tween 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름, 트윈 옵션 값 + 모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) - `Attribute` 제네릭 vs 타입별 정적 생성자 (`bind-system-plan.md`) - provider/processor 네이밍 (`module-lifecycle-plan.md`) - 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) +- Effect가 `state:Effect()`로 Observer를 확장하는 형태인지, 완전히 독립된 + free function인지 (`base/additional-primitives.md`의 "미해결" 절) 이 항목들은 `.claude/question.md`에도 이미 열린 질문으로 잡혀있음 — 여기선 "확정 전엔 문서화 대상 아님"이라는 표시만 겸함. diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md index f65f406..c8c7b07 100644 --- a/.claude/research/documentation-plan.md +++ b/.claude/research/documentation-plan.md @@ -102,7 +102,7 @@ Roblox가 Play 중 라이브 UI 편집 도구를 꺼버려서 실제 화면의 U 강제할까(과한 선택지, 참고만)? **구체적으로 결정된 하위 규칙 하나(2026-08-06)**: quad가 내부적으로 -자동 생성하는 helper Instance(예: `research/ui-shorthand-plan.md`의 +자동 생성하는 helper Instance(예: `base/ui-shorthand-plan.md`의 UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같은 접두어를 붙여 Explorer에서 직접 볼 때 사용자가 만든 것과 헷갈리지 않게 함 — v1의 `_quad_round`류 네이밍 재사용(`research/debug-tooling-plan.md` diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index e7d497c..c3302cc 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -442,7 +442,7 @@ M11 착수 시. ### 2-11. UI shorthand — 기존 UICorner 자식과의 매칭 기준(이름 vs 타입)이 불명, 사용자 실수 유발 위험 높음 -**위치**: `research/ui-shorthand-plan.md` "v1 실제 메커니즘" 절. +**위치**: `base/ui-shorthand-plan.md` "v1 실제 메커니즘" 절. **문제**: v1 메커니즘은 "기존 `UICorner` 자식이 있으면 재사용, 없으면 `Instance.new("UICorner", item)`(`Name = "_quad_round"`)"라고 서술되는데, @@ -485,7 +485,7 @@ M11 착수 시. ### 3-2. Corner/PaddingAll/Scale 개별 Handler 대신 데이터 테이블 구동 단일 제네릭 Handler -**위치**: `research/ui-shorthand-plan.md` "메커니즘 — 새 아키텍처 개념 +**위치**: `base/ui-shorthand-plan.md` "메커니즘 — 새 아키텍처 개념 불필요" 절. **문제**: 문서는 "Corner/PaddingAll/Scale 같은 특수 키를 인식하는 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index cc34469..872fd6c 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -2,12 +2,16 @@ **상태**: research — 방향은 뚜렷하게 잡혀 있고(라이브러리가 트윈을 직접 구현하지 않는다), `retract` 순서/오버라이드 기본값(Cancel)도 확정됨. 남은 건 -기본값 외 나머지 오버라이드 동작을 고르는 옵션 키의 정확한 이름/시그니처 -정도. 원본: +기본값 외 나머지 오버라이드 동작을 고르는 옵션 키의 정확한 이름/시그니처와, +트윈 옵션을 어떤 값 모양으로 받을지(아래 "트윈 옵션 값 모양" 절, 신규) +정도. **중요 — 놓치기 쉬운 포인트**: `retract`는 Destroy(완전 소멸) 시엔 +호출되지 않는다(아래 "`retract`는 완전 소멸 시엔 호출되지 않는다" 절) — +Tween 오버라이드 로직을 짤 때 "인스턴스가 파괴될 때도 이 코드가 실행될 +것"이라고 가정하면 틀림. 원본: `.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은 항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이 반응 그래프 안에 있는 설계는 명시적 반면교사 — [정정: 절 제목이 실제와 -달랐음] `base/comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿 +달랐음] `reference/comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿 ("Tween/Spring이 State그래프 안의 1급 노드") 참고. ## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 @@ -79,6 +83,44 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind Destroy 시점엔 아무 것도 안 함(라이프타임 `Connected` 체크로 처리 자체를 멈추는 것만으로 충분).** +**메모 — `retract`와 `canExecute`는 서로 다른 문제를 다룬다, 나중에 quadnomicon +급에서 제대로 설명 필요.** "그럼 값 교체가 아니라 값을 계속 관측하는 쪽 +(예: `state:Observer(fn)`으로 Tween을 건 경우)은 Destroy 시 어떻게 +정리되는가?"라는 질문이 자연스럽게 따라오는데, 이건 `retract`의 영역이 +아니라 `canExecute`(라이프타임 predicate, `base/lifecycle-pattern.md`의 +"생명 바인드 유틸" 절)의 영역이다 — Destroy되면 `retract` 호출 없이 그냥 +`canExecute`가 false가 되어 이후 처리 시도 자체가 조용히 no-op된다. +store-bind 일반(Tween 포함)도 같은 결이라 실제로는 이미 일관되게 명시돼 +있지만(`base/bind-system-plan.md` "확정된 디스패치 모델" 절), "왜 이 +경로엔 retract를 쓰고 저 경로엔 canExecute를 쓰는가"라는 내부 구조상의 +이유는 quadnomicon 콘텐츠로 풀어서 설명할 필요가 있음(`research/ +documentation-content-map.md` 심화 콘텐츠 후보에 메모) — 지금은 이 메모만 +남겨두고 상세 설명은 나중 문서화 단계로 미룸. + +## 트윈 옵션 값 모양 — 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. **편의 필드로 개별 인자를 받고 기본값을 제공** — 예: + `{Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼 + 이름 붙은 키로 받고 흔한 기본값(예: `Time=0.2`, `Style=Quad`, + `Direction=Out`)을 채워줌. 명시적으로 `TweenInfo`가 이미 있어서 + 재사용하고 싶다는 케이스에도 열어두면(예: `Info = someTweenInfo` + 필드로), 둘 다 지원 가능. + +**현재 소견(확정 아님)**: 2번(편의 필드 + 기본값)이 흔한 사용 경험상 더 +낫다는 쪽으로 기움 — 대부분의 호출에서 named call이 없는 `TweenInfo.new`의 +가독성 문제를 피할 수 있고, 기본값 덕에 짧은 호출도 가능해짐. 다만 +구체적인 필드 이름/기본값/`TweenInfo` 재사용 경로의 정확한 문법은 아직 +확정 아님 — 나중 논의 대상으로 남김. + ## 네임스페이스드 객체 (성능상 이유로 보류) 트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 — @@ -92,3 +134,7 @@ CollectionService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페 - 기본값(Cancel)은 확정됨. 남은 건 나머지 세 동작(오버라이드/삭제 후 재시작/ 끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처 — 구현 단계에서 확정. +- 트윈 옵션 값 모양(위 "트윈 옵션 값 모양" 절, 신규) — `TweenInfo` 그대로 + 받을지 편의 필드+기본값으로 받을지, 소견은 후자 쪽이지만 확정 아님. +- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부 — + `research/pre-implementation-audit.md` 2-10번 참고, M11 착수 시 확정. diff --git a/.claude/research/ui-shorthand-plan.md b/.claude/research/ui-shorthand-plan.md deleted file mode 100644 index 928e99c..0000000 --- a/.claude/research/ui-shorthand-plan.md +++ /dev/null @@ -1,102 +0,0 @@ -# UI 편의 숏핸드 (Corner/Padding/Scale 등) — 인라인 적용 계획 - -**상태**: research — 2026-08-06 세션에서 결론까지 남. `Corner`/ -`PaddingAll`/`Scale` 숏핸드 자체는 **여전히 필요**(사용자 재확정, 아래 -"결론" 절 — 이전에 이 문서가 한 차례 "포팅 불필요"로 잘못 정리했던 걸 -정정함). 패키지 배치는 `quad-roblox` 코어 직접 포함으로 확정. - -## 배경 - -사용자 기억: 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종을 지원: - -- `RoundSize = 16` → `ImageLabel`/`ImageButton` 전용, UICorner가 아니라 - 이미지 자체의 9-slice 라운드 처리(`round.SetRound()`) — **UICorner 계열과 - 메커니즘이 다름**. -- `Corner = 8` → 숫자 하나. 기존 `UICorner` 자식이 있으면 재사용, 없으면 - `Instance.new("UICorner", item)`으로 생성(`Name = "_quad_round"`), - `CornerRadius = UDim.new(0, value)` 설정. -- `PaddingAll = UDim.new(...)` / `PaddingAllOffset = 50` → 동일 패턴, - `UIPadding`(`_quad_padding`). -- `Scale = 1.2` → 동일 패턴, `UIScale`(`_quad_scale`). - -값 모양은 항상 **리터럴 하나**(숫자/UDim) — 테이블도 `__type` 태그도 아님. -실사용 예시(`md/kr/tutorial/7_quadProperty.md`): -```lua -Frame "mainFrame" { - PaddingAllOffset = 50; - ImageFrame { RoundSize = 16; ... }; -} -``` - -**`UIListLayout`/`UIGridLayout`/flex는 이런 전용 숏핸드가 v1에 없었음** — -`ProcessQuadProperty`의 범용 자식 나열 분기(배열 인덱스로 놓인 Instance/ -Class 결과를 자동 mount, 207~213행)로 `UIListLayout{...}`을 그냥 직접 -나열했을 뿐, `List = true` 같은 전용 축약 문법은 레포 전체(PA님 코드 -포함)에서 찾지 못함. **quad-v2도 이 부분은 이미 있는 children-array + -인스턴스 생성 문법으로 그대로 커버됨 — 새로 설계할 것 없음.** 사용자 -기억 중 이 부분은 "전용 숏핸드"가 아니라 "선언형 문법 자체가 원래 -간결하다"는 것과 섞였을 가능성이 큼. - -## 결론 (2026-08-06, 한 차례 오해 후 재정정) - -**RoundSize와 Corner는 서로 다른 이유로 존재했던 별개 기능 — 혼동하지 -말 것**: -- **`RoundSize`(이미지 9-slice 라운드)**: `ImageLabel`/`Button`을 - 이미지 트릭으로 둥글게 보이게 하던 것 — **당시 Roblox에 `UICorner` 같은 - 네이티브 구현체가 없었기 때문에** 존재하던 워크어라운드. 지금은 - `UICorner`가 안정적인 네이티브 Instance라 이 이미지 트릭 자체를 그대로 - 포팅할 이유는 없음(이미지에도 그냥 실제 `UICorner`를 쓰면 됨) — - **RoundSize는 포팅 안 함**. -- **`Corner`/`PaddingAll`/`Scale`(UICorner/UIPadding/UIScale 자동 - 생성)**: 이건 워크어라운드가 아니라 **지금도 유효한 편의 기능** — - **사용자 재확정**: "UIScale 같은 건 여전히 별도의 Instance고 부모 - Frame에 영향을 주는 구조, 숏핸드는 여전히 필요하다". `UICorner`가 - 네이티브가 됐다고 해서 "별도 Instance를 만들어 부모에 Parent해야 - 한다"는 구조적 번거로움 자체가 없어지는 게 아니므로, 이 숏핸드의 - 존재 이유는 여전히 유효함 — **이전 정리("포팅 불필요")는 오해였고 - 정정함, `Corner`/`PaddingAll`/`Scale`은 그대로 포팅 대상.** - -**메커니즘 — 새 아키텍처 개념 불필요**: 이미 있는 pluggable Handler로 -그대로 커버됨. `Corner`/`PaddingAll`/`Scale` 같은 특수 키를 인식하는 -Handler(`isHandlable`이 그 키를 매칭)가 "이름 붙은 자식을 찾거나 만들고 -프로퍼티 세팅"을 `process(inst, k, v)`에 구현 — v1의 하드코딩 if/elseif -대신 정식 핸들러 계약(`isHandlable`/`priority`/`process`/`retract`)을 -따르는 것만 다름. `modifier-plan.md`가 이미 예시로 든 -`Modifier.Rounded(8)`은 이 특수 키를 flatten해서 props에 꽂아넣는 사탕 -문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고 -`Frame { Corner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게 -작동함, `architecture.md`의 `[Attribute "Name"]`류 특수 키와 같은 층위. -자동 생성된 자식은 위 "핵심 설계 방향" 관례대로 `_`/`QUAD_` 접두어 -네이밍(`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 -그대로 재사용). - -**패키지 배치 — `quad-roblox` 코어에 직접 포함, 확정**: "트윈도 인스턴스 -생성/제어를 직접 구현 가능한 걸 하나로 묶어 쉽게 쓰게 합친 것 — 너무 -잘게 쪼개 오버엔지니어링하기보다 확실히 하나로 코어에 넣어도 충분하다, -opt-out할 이유가 별로 없다"는 게 사용자 판단 — **작고 항상 켜져 있어도 -비용이 무시할 만한 편의 기능은 별도 opt-out 패키지로 쪼개지 말고 -`quad-roblox` 코어에 직접 포함한다**는 원칙으로 확정(이미 계획된 Tween -핸들러가 같은 모양이라는 게 근거). 이 원칙은 일반화해서 재사용 가능 — -앞으로 비슷한 "작은 인스턴스 편의 기능"이 제안되면 `quad-roblox-util` -같은 걸 새로 만들지 않고 이 선례를 따르면 됨. - -**중요도**: 낮음("이건 나중에도 쉽게 구현됨" — 사용자) — 지금 M0 우선순위를 -바꿀 이유는 없음, M10(Handlers/Attribute 등) 전후로 다른 세부 Handler와 -함께 구현하면 충분. - -## 열린 질문 (`.claude/question.md`에도 취합) - -- 이름 그대로 가져올지(`Corner`/`PaddingAll`/`PaddingAllOffset`/`Scale`) - 재검토할지 — 진행 중인 용어 정리(`CLAUDE.md` "지금 할 일" 2번)에 합류 - 대상. -- `RoundSize`(이미지 라운드)를 완전히 드롭할지, 아니면 이미지 대상에도 - 그냥 실제 `UICorner`를 자동 적용하는 것으로 대체할지 — 후순위. diff --git a/.claude/research/v1-compat-plan.md b/.claude/research/v1-compat-plan.md index 6701149..02b8cb0 100644 --- a/.claude/research/v1-compat-plan.md +++ b/.claude/research/v1-compat-plan.md @@ -61,7 +61,7 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층 핸들러 분기도 안 생김. 단, "quad-debug 추적 밖 mutate 경로가 생긴다"는 근거(4번)는 격리해도 남는 문제라 별도 검토 필요. - **RoundSize 등 특수 키**: `Corner`/`PaddingAll`/`Scale`은 이미 - `research/ui-shorthand-plan.md`에서 네이티브 포팅 확정됨 — 별도 compat + `base/ui-shorthand-plan.md`에서 네이티브 포팅 확정됨 — 별도 compat 작업 불필요. `RoundSize`(이미지 9-slice 라운드 트릭)만 UICorner 없던 시절 워크어라운드라 재현 자체가 불필요하다고 이미 결론남. - **`target()`/Linker**: 위 정정대로 "이름 있는 자식 등록 + 시그널 중계"일 diff --git a/CLAUDE.md b/CLAUDE.md index e853732..f33c44d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,10 +38,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `.claude/README.md`가 색인. 요약: - `.claude/base/` — 확정된 아키텍처/컨텍스트, plan/done 개념 없음. 먼저 `.claude/base/architecture.md`를 읽을 것. +- `.claude/reference/` — **[2026-08-07 신설]** base처럼 확정된 건 아니지만 + base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷, + Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것. - `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. `tween-plan.md`/`existing-instance-bind-plan.md`/`debug-tooling-plan.md`/ - `ui-shorthand-plan.md`/`documentation-plan.md`/`documentation-content-map.md`/ - `framework-comparison-findings.md` — 전부 후순위(급한 건 `tween-plan.md` + `documentation-plan.md`/`documentation-content-map.md`/ + `framework-comparison-findings.md`/`additional-primitives-plan.md`(키 기반 + 동적 컬렉션 재조정만 남음) — 전부 후순위(급한 건 `tween-plan.md` 세부 옵션 정도). 최신 목록·우선순위는 `.claude/README.md`가 소스, 여기서 개수 반복 안 함(과거에 "두 개뿐"이라 적어놨다가 새 문서 추가될 때마다 안 갱신되는 패턴이 반복돼서 아예 안 세기로 함). @@ -676,3 +680,71 @@ M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. 풀리는 문제 — `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 `None` (가칭) 프리미티브를 도입하는 방향만 `base/modifier-plan.md` "2-1"절에 짧게 메모해두고 상세 설계는 다음 세션으로 미룸. + +## 2026-08-07 네 번째 세션 — `.claude/` 코퍼스 전반 정리(폴더 재편, 승격, 기각 분리) + +사용자가 코퍼스 전체를 훑고 "실제 코딩에 필요한가"를 기준으로 남길 것과 +분리할 것을 판단해 달라고 요청 — 여러 문서에 쌓인 역전 이력/quad +자체와 무관한 배경자료/이미 기각된 후보가 뒤섞여 있어 컨텍스트 크기와 +가독성 둘 다 해치고 있다는 문제의식. 아래 6가지를 처리, 전부 반영 완료: + +1. **`reference/` 폴더 신설** — `quad-v1-architecture.md`, + `comparison-fusion-vide.md`를 `base/`에서 이동. 항상 읽어야 하는 + 결정사항(`base/`)과, 다른 문서가 근거로 인용할 때만 열어보면 되는 + 온디맨드 스냅샷/비교자료(`reference/`)를 분리 — 전자는 "결정 완료", + 후자는 "결정이 아니라 결정의 근거"라는 차이. 전체 문서의 상호참조 + 경로도 전부 갱신함. +2. **`component-composition-plan.md`의 누적 역전 이력 트리밍** — + `StoreSource` 프록시 폐기 이력이 "원래 이랬다 → 이렇게 뒤집혔다"를 + 본문에서 장황하게 반복 서술하고 있었는데, 이미 `archive/ + store-source-proxy-reversed.md`에 원문·이유·비교표가 전부 보존돼 + 있으므로 본문은 최종 확정만 남기고 포인터로 압축. +3. **`ui-shorthand-plan.md`를 `research/`→`base/`로 승격, 재작성** — + (a) 이미지 라운드 트릭 `RoundSize`는 완전히 드롭, 근거는 + `archive/ui-shorthand-roundsize-dropped.md`로 분리(이 판단이 한 차례 + "Corner/PaddingAll/Scale 전체가 불필요하다"로 잘못 일반화됐다가 + 정정된 이력도 같이 보존). (b) 이름을 v1 그대로(`Corner`/`PaddingAll`/ + `Scale`)가 아니라 실제 Roblox Instance 이름과 맞춘 `UICorner`/ + `UIPadding`/`UIScale`로 확정 — v1식 짧은 이름은 Modifier 체이닝 + 메소드와 겹쳐 "진짜 UICorner 숏핸드인지 그냥 비슷한 이름의 부가 + Modifier인지" 구분이 안 된다는 사용자 지적 반영. (c) store-bind + 가능성 명시 — v1에서도 가능했던 기능이고, Tween처럼 무거운 API + 표면 없이 기존 per-instance weak-table 유틸(`base.perInstanceState`) + 재사용만으로 충분하다는 점을 추가. +4. **`additional-primitives-plan.md`를 4갈래로 분리**: 확정된 `Blocker`/ + `Effect`는 새 `base/additional-primitives.md`로 승격(Blocker는 + State와 같은 마일스톤에서 개발하기로 해서 `store-semantics.md`에 + 교차 참조 추가, `ROADMAP.md` M3에도 체크박스 반영). 기각된 `Batch` + (lexical block)와 `Context`(+대안이던 레이어드 Store)는 각각 + `archive/batch-rejected.md`/`archive/context-rejected.md`로 분리. + `research/additional-primitives-plan.md`엔 아직 실제로 열려있는 + 것(키 기반 동적 컬렉션 재조정) 하나만 남김. +5. **archive 제목 컨벤션을 둘로 분화** — 기존 `[역전됨]`(한 번 확정했다가 + 뒤집힌 것, `store-source-proxy-reversed.md`/`ref-phase-option-reversed.md`)과 + 새로 생긴 `[기각됨]`(확정한 적 없이 후보였다가 채택 안 된 것, + `batch-rejected.md`/`context-rejected.md`/`ui-shorthand-roundsize-dropped.md`)을 + 구분 — `README.md`의 `archive/` 폴더 기준 설명에 두 컨벤션 차이를 + 명시. +6. **`tween-plan.md` 보강** — `retract`가 Destroy 시엔 호출 안 된다는 + 사실을 상단 상태 요약에서도 짚도록 가시성 강화, `canExecute`(Destroy + 시 처리)와 `retract`(값 교체 시 처리)가 서로 다른 문제를 다룬다는 + 점을 quadnomicon급 문서화 숙제로 메모(지금은 상세 설명 안 하고 + 메모만). 트윈 옵션 값 모양(raw `TweenInfo` vs 이름 붙은 편의 + 필드+기본값) 논의를 새로 열어둠 — Luau가 named call을 지원 안 해서 + `TweenInfo.new(...)` 포지셔널 생성자가 읽기 어렵다는 문제의식, + 소견은 편의 필드 쪽이지만 확정 아님, 나중 논의 대상으로만 남김. + +**미해결로 남긴 것 — 임의로 결론내지 않음**: Effect가 `state:Effect()` +형태로 Observer를 확장하는 변형인지, 완전히 독립된 free function인지가 +불명확함(사용자가 "확인 필요, 아니라면 논의해야 할 상태로 남겨두라"고 +명시). 관련 하위 질문으로 `state:Observer(fn)`가 생성 시 `fn`을 즉시 +1회 실행하는지도 문서 어디에도 명시돼 있지 않음이 이번에 드러남(Effect는 +"즉시 1회 실행"이 스펙에 명시돼 있어 이 부분만 보면 둘이 겹쳐 보임). +`base/additional-primitives.md`의 "미해결" 절과 `.claude/question.md` +0번에 반영 — 구현 착수(M3~M4 전후) 전에 반드시 재확인할 것. + +**다음 세션이 할 일**: 안 바뀜(위 2026-08-06 네 번째 세션 절 "다음 세션이 +할 일" 참고, `ROADMAP.md` M0부터). 이번 세션은 순수 문서 정리라 설계 +결정 자체는 늘지 않았음 — 단, M3 체크리스트에 `Blocker.luau` 항목이 +하나 추가된 것과, 위 Effect/Observer 미해결 항목은 M3~M4 착수 전에 +확인해야 함. diff --git a/ROADMAP.md b/ROADMAP.md index 7861367..b2a7bc2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,12 +52,21 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Dispatch/init.luau`(`process`/`retract` 엔진, `isHandlable` 우선순위 스캔) - [ ] `Handler.luau`(핸들러 계약 타입) +- [ ] `LifetimeHandle.luau`/`PerInstanceState.luau` **인터페이스만**(타입 + 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 + 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 + 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 + 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` 2번 + — 2026-08-07 세 번째 세션에 반영) - [ ] mock 대상 테스트 ## M3 — Store/State/Source - [ ] `Source.luau`/`State.luau`/`Store.luau` - [ ] `store.key` dot-access 타입 추론 확인 +- [ ] `Blocker.luau`(`base/additional-primitives.md` 참고 — 여러 Source를 + 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, + State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) - [ ] mock 대상 테스트 ## M4 — 첫 end-to-end 반응형 업데이트 @@ -87,12 +96,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) +- [ ] 인라인 키로 modifier 필드를 명시적으로 지우는 문제 확인 — `None` + (가칭) 센티널 프리미티브 도입 여부(`modifier-plan.md` 2-1번, 아직 + 미정 — 착수 전 사용자 확인 필요, 확정 안 되면 이번 마일스톤은 + 스킵하고 다음으로 미뤄도 됨) ## M8 — Ref -- [ ] `CreatedRef` 메커니즘(숫자 슬롯 참가자) -- [ ] `LifetimeHandle` 인터페이스 + quad-roblox 실제 구현(Instance 생존 확인) -- [ ] `PerInstanceState` 인터페이스 + quad-roblox 실제 구현(weak-keyed table) +- [ ] `CreatedRef` 메커니즘(숫자 슬롯 참가자) + `PreRef`(children 배열 + 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — + `base/bind-system-plan.md` "`phase` 옵션 폐기 → 위치로 표현, + `PreRef` 신설" 절) +- [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 resume+소진, + 함수면 호출+유지 — 같은 배열 하나로 통합) +- [ ] `LifetimeHandle` quad-roblox 실제 구현(Instance 생존 확인, 인터페이스 + 자체는 M2로 이동됨) +- [ ] `PerInstanceState` quad-roblox 실제 구현(weak-keyed table, 인터페이스 + 자체는 M2로 이동됨) ## M9 — 컴포넌트 합성 레이어 From 226490153e643493d381f23ea36d30163e73f003 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 14:45:57 +0900 Subject: [PATCH 33/62] =?UTF-8?q?docs:=20Blocker/Effect=EB=A5=BC=20base/ad?= =?UTF-8?q?ditional-primitives.md=EC=97=90=EC=84=9C=20=EB=B3=84=EA=B0=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=9E=AC=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State 작업 시 Effect까지 같이 볼 필요는 없다는 지적(둘은 무관한 프리미티브)과 기존 프리미티브당 1파일 컨벤션(modifier-plan.md, slot-plan.md류)에 맞춰 base/blocker-plan.md, base/effect-plan.md로 재분리. Blocker는 store-semantics.md 교차 참조를 유지, Effect는 독립 파일로 완전히 분리. 전체 상호참조 경로 갱신. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 7 +- .claude/archive/batch-rejected.md | 2 +- ...ditional-primitives.md => blocker-plan.md} | 83 +++---------------- .claude/base/effect-plan.md | 60 ++++++++++++++ .claude/base/store-semantics.md | 2 +- .claude/question.md | 9 +- .../research/additional-primitives-plan.md | 12 +-- .claude/research/documentation-content-map.md | 4 +- CLAUDE.md | 22 +++-- ROADMAP.md | 2 +- 10 files changed, 107 insertions(+), 96 deletions(-) rename .claude/base/{additional-primitives.md => blocker-plan.md} (53%) create mode 100644 .claude/base/effect-plan.md diff --git a/.claude/README.md b/.claude/README.md index 99659a1..2cbd6a9 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -34,7 +34,8 @@ | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정, getter 이름만 남음 | | `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` 포인터로 압축 | -| `additional-primitives.md` | **[2026-08-07 신설]** `Blocker`(여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤과 함께 개발)와 `Effect`(leaf 죽음에 확정 정리) — Blocker는 메커니즘+이름 확정, Effect는 Observer와의 관계가 아직 미해결(문서 내 "미해결" 절, `question.md` 0번) | +| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | +| `effect-plan.md` | **[2026-08-07 신설]** `Effect` — leaf 죽음에 확정 정리, 재실행 개념 없음. Observer와의 관계가 아직 미해결(문서 내 "미해결" 절, `question.md` 0번) | | `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` 참고 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) @@ -54,7 +55,7 @@ | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | | `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 | | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 진짜 불리한 점 중 고칠 만한 것 3개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 | -| `additional-primitives-plan.md` | **[2026-08-07 범위 축소]** 확정/기각된 Effect·Blocker·Batch·Context는 `base/additional-primitives.md`·`archive/`로 분리됨 — 이제 **키 기반 동적 컬렉션 재조정**(Fusion `ForPairs`/Vide `indexes()`류에 대응하는 프리미티브가 quad엔 없음) 하나만 다룸 | 상 — 사용자 판단 대기, 착수 전 M0/M1 스코프에 영향 줄 수 있음 | +| `additional-primitives-plan.md` | **[2026-08-07 범위 축소]** 확정/기각된 Effect·Blocker·Batch·Context는 `base/blocker-plan.md`·`base/effect-plan.md`·`archive/`로 분리됨 — 이제 **키 기반 동적 컬렉션 재조정**(Fusion `ForPairs`/Vide `indexes()`류에 대응하는 프리미티브가 quad엔 없음) 하나만 다룸 | 상 — 사용자 판단 대기, 착수 전 M0/M1 스코프에 영향 줄 수 있음 | | `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 코어 구현 시점까지 미결 | @@ -65,7 +66,7 @@ | `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/additional-primitives.md`)로 대체 | +| `batch-rejected.md` | **[기각됨, 2026-08-07 신설]** lexical `Batch(fn)` — 코루틴 yield 위에서 구조적으로 위험해 기각, 값 기반 `Blocker`(`base/blocker-plan.md`)로 대체 | | `context-rejected.md` | **[기각됨, 2026-08-07 신설]** `Context`(트리 하위 암묵 전파) + 대안이던 레이어드 Store 둘 다 기각 — 명시적 타입 강제 Store 전달로 충분하다는 판단 | ## 참고 diff --git a/.claude/archive/batch-rejected.md b/.claude/archive/batch-rejected.md index 52a52f5..d0ada8b 100644 --- a/.claude/archive/batch-rejected.md +++ b/.claude/archive/batch-rejected.md @@ -1,7 +1,7 @@ # [기각됨] `Batch(fn)` — lexical block 기반 지연/합치기, `Blocker`로 대체됨 **기각 일시**: 2026-08-06~07. **현재 유효한 설계**: `base/ -additional-primitives.md`의 "Blocker" 절 — 이 문서가 다루는 것과 같은 +blocker-plan.md` — 이 문서가 다루는 것과 같은 문제("여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게")를 값 기반으로 풀어 대체함. 이 파일은 더 이상 능동적으로 참고할 필요 없음 (구현에 안 씀) — "왜 lexical Batch를 기각하고 값 기반 Blocker를 택했는가"가 diff --git a/.claude/base/additional-primitives.md b/.claude/base/blocker-plan.md similarity index 53% rename from .claude/base/additional-primitives.md rename to .claude/base/blocker-plan.md index 4c0d60d..bc98d88 100644 --- a/.claude/base/additional-primitives.md +++ b/.claude/base/blocker-plan.md @@ -1,12 +1,12 @@ -# 추가 확정 프리미티브 — Blocker / Effect +# Blocker — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게 -**상태**: base — `research/additional-primitives-plan.md`(다른 프레임워크 대비 -갭 분석)에서 갈라져 나온 두 확정 프리미티브. Batch(lexical block)/Context는 -기각되어 각각 `archive/batch-rejected.md`/`archive/context-rejected.md`로, -아직 미확정인 키 기반 동적 컬렉션 재조정은 `research/additional-primitives-plan.md`에 -그대로 남아있음 — 이 문서는 **확정된 것만** 다룬다. - -## 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`처럼 여러 소스가 한 파생값에 합류할 때, 둘을 한 번에 바꾸면 소비자에게 두 번 전파(재계산+재대입)되는 @@ -22,7 +22,7 @@ bind-system-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것. -### 메커니즘 (확정) +## 메커니즘 (확정) ``` Blocker() -> blocker -- 생성자 @@ -47,7 +47,7 @@ gated state의 동작: 누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서 준다 — `store-semantics.md`의 "`Get()`은 라이브 레퍼런스를 준다" 원칙과 일치. -### 사용 예시 +## 사용 예시 `state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Block`을 건다: @@ -67,7 +67,7 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다. -### 이름 확정 +## 이름 확정 - 클래스: `Blocker` — `Observer`/`Modifier`/`Ref`와 같은 명사-행위자 네이밍 관례와 일치. @@ -80,7 +80,7 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 (gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌). - 메소드: `state:Block(blocker) -> state`. -### 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 +## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 `IsBlocked`는 카운터가 아니라 단순 불리언이고, **의도적으로 그렇게 둔다.** 레퍼런스 카운팅으로 네스팅을 지원할 수도 있었지만, 그러면 "`On()` 여러 @@ -94,64 +94,7 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. -### 상태: 핵심 메커니즘+이름 확정. 남은 건 문서화뿐 +## 상태: 핵심 메커니즘+이름 확정. 남은 건 문서화뿐 `quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용). - ---- - -## Effect — leaf 죽음에 확정 정리, 재실행 개념 없음 - -**Observer와는 별개의, 완전히 새로운 요소로 확정** — Ref/PreRef 같은 -서로 파생된 관계가 아니라 독립적으로 존재하는 primitive. Roblox엔 -`task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이 흔하고, Luau -테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는 순간"을 아는 -유일한 방법은 `Instance.Destroying`류 명시적 신호뿐 — 이런 케이스(타이머 -시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive로 합의됨. - -``` -Effect(fn) -> EffectHandle -- fn을 즉시 1회 실행, 리턴값(nil | () -> ())은 - -- 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출 -``` - -**재실행 개념이 없다** — 값 변화에 반응해 다시 도는 건 Observer(+클로저로 -직접 짠 cleanup)의 영역이고, Effect는 순수하게 "설치 + 확정 정리" 페어 -하나만 담당한다. children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 -그대로 재사용(그 leaf가 살아있는 동안만 유효, leaf가 죽으면 정리 콜백 -호출). 비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 -Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분. - -**Observer에 cleanup 반환 계약을 추가하는 안은 기각됨** — React `useEffect`류로 -`fn`이 `nil | () -> ()`를 반환하면 다음 재실행 직전에 그걸 불러주는 안을 -검토했으나, 클로저 업밸류로 이미 쉽게 되고 잘 작동해서(`local lastConn; -state:Observer(function() if lastConn then lastConn:Disconnect() end; -lastConn = ... end)`) 프레임워크가 이걸 대신해줄 이유가 약하다는 판단 — -`state:Observer(fn)` 자체는 여전히 재실행 계약만 갖고, Effect가 별도로 -"1회 설치 + 확정 정리"를 담당하는 이 분리 구조가 유지됨. - -### ⚠️ 미해결 — Effect와 Observer의 관계, 사용자 확인 필요 - -**임의로 결론내지 않고 열어둠(2026-08-07 문서 정리 세션)**: 위 스펙은 -`Effect(fn)`를 State에 종속되지 않는 완전한 자유 함수로 서술하지만, 아래 -두 가지가 문서상 명확히 확인되지 않음: - -1. **`Effect`가 실제로는 `state:Effect(fn)`처럼 State의 메소드(=Observer의 - 변형, "재실행 없음 + 확정 정리 추가"만 다른 버전)로 구현/노출되어야 - 하는 것 아닌가?** — 그렇다면 "독립 존재 가능한 프리미티브 vs 원천에 - 종속된 파생 데이터"(`base/store-semantics.md`) 분류상 Effect도 - Observer처럼 후자(자유 함수 생성자 없음, 항상 `:` 메소드)로 재분류해야 - 함. 지금 이 문서는 이전 조사(`research/additional-primitives-plan.md`)를 - 따라 자유 함수로 서술했지만, 이게 최종 확정인지는 불명확. -2. **`state:Observer(fn)`가 생성 시점에 `fn`을 즉시 1회 실행하는지가 문서 - 어디에도 명시돼 있지 않음** — `base/bind-system-plan.md`의 Observer - 절은 "값을 안 실어줌, `fn` 본문에서 `Get()`을 다시 읽어야 함"만 - 명시할 뿐 "생성 즉시 1회 호출되는지"는 다루지 않는다. Effect는 - "즉시 1회 실행"이 스펙에 명시돼 있어 이 부분만 보면 둘이 겹쳐 - 보인다. - -이 두 질문이 풀리면 Effect가 (a) 완전히 별개인 자유 함수 primitive로 -남는지, (b) `state:Effect(fn)`로 Observer 계열에 합류하는지가 갈린다. -**확인 전까지는 위에 적은 자유 함수 스펙을 잠정 스펙으로 두되, 구현 -착수(M3~M4 전후) 전에 반드시 사용자와 다시 확인할 것** — `.claude/ -question.md`에 같은 항목 등재됨. diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md new file mode 100644 index 0000000..e8e7627 --- /dev/null +++ b/.claude/base/effect-plan.md @@ -0,0 +1,60 @@ +# Effect — leaf 죽음에 확정 정리, 재실행 개념 없음 + +**상태**: base — `research/additional-primitives-plan.md`(다른 프레임워크 +대비 갭 분석)에서 갈라져 나온 확정 프리미티브. `base/blocker-plan.md`(같은 +조사에서 나온 다른 확정 프리미티브)와는 서로 무관 — Effect는 Store/State +작업이나 Ref/PreRef와도 파생 관계가 아닌 완전히 독립된 요소라 별도 파일로 +둔다(2026-08-07 문서 정리에서 한 파일로 합쳤던 걸 다시 분리). + +**Observer와는 별개의, 완전히 새로운 요소로 확정** — Ref/PreRef 같은 +서로 파생된 관계가 아니라 독립적으로 존재하는 primitive. Roblox엔 +`task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이 흔하고, Luau +테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는 순간"을 아는 +유일한 방법은 `Instance.Destroying`류 명시적 신호뿐 — 이런 케이스(타이머 +시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive로 합의됨. + +``` +Effect(fn) -> EffectHandle -- fn을 즉시 1회 실행, 리턴값(nil | () -> ())은 + -- 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출 +``` + +**재실행 개념이 없다** — 값 변화에 반응해 다시 도는 건 Observer(+클로저로 +직접 짠 cleanup)의 영역이고, Effect는 순수하게 "설치 + 확정 정리" 페어 +하나만 담당한다. children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 +그대로 재사용(그 leaf가 살아있는 동안만 유효, leaf가 죽으면 정리 콜백 +호출). 비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 +Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분. + +**Observer에 cleanup 반환 계약을 추가하는 안은 기각됨** — React `useEffect`류로 +`fn`이 `nil | () -> ()`를 반환하면 다음 재실행 직전에 그걸 불러주는 안을 +검토했으나, 클로저 업밸류로 이미 쉽게 되고 잘 작동해서(`local lastConn; +state:Observer(function() if lastConn then lastConn:Disconnect() end; +lastConn = ... end)`) 프레임워크가 이걸 대신해줄 이유가 약하다는 판단 — +`state:Observer(fn)` 자체는 여전히 재실행 계약만 갖고, Effect가 별도로 +"1회 설치 + 확정 정리"를 담당하는 이 분리 구조가 유지됨. + +## ⚠️ 미해결 — Effect와 Observer의 관계, 사용자 확인 필요 + +**임의로 결론내지 않고 열어둠(2026-08-07 문서 정리 세션)**: 위 스펙은 +`Effect(fn)`를 State에 종속되지 않는 완전한 자유 함수로 서술하지만, 아래 +두 가지가 문서상 명확히 확인되지 않음: + +1. **`Effect`가 실제로는 `state:Effect(fn)`처럼 State의 메소드(=Observer의 + 변형, "재실행 없음 + 확정 정리 추가"만 다른 버전)로 구현/노출되어야 + 하는 것 아닌가?** — 그렇다면 "독립 존재 가능한 프리미티브 vs 원천에 + 종속된 파생 데이터"(`base/store-semantics.md`) 분류상 Effect도 + Observer처럼 후자(자유 함수 생성자 없음, 항상 `:` 메소드)로 재분류해야 + 함. 지금 이 문서는 이전 조사(`research/additional-primitives-plan.md`)를 + 따라 자유 함수로 서술했지만, 이게 최종 확정인지는 불명확. +2. **`state:Observer(fn)`가 생성 시점에 `fn`을 즉시 1회 실행하는지가 문서 + 어디에도 명시돼 있지 않음** — `base/bind-system-plan.md`의 Observer + 절은 "값을 안 실어줌, `fn` 본문에서 `Get()`을 다시 읽어야 함"만 + 명시할 뿐 "생성 즉시 1회 호출되는지"는 다루지 않는다. Effect는 + "즉시 1회 실행"이 스펙에 명시돼 있어 이 부분만 보면 둘이 겹쳐 + 보인다. + +이 두 질문이 풀리면 Effect가 (a) 완전히 별개인 자유 함수 primitive로 +남는지, (b) `state:Effect(fn)`로 Observer 계열에 합류하는지가 갈린다. +**확인 전까지는 위에 적은 자유 함수 스펙을 잠정 스펙으로 두되, 구현 +착수(M3~M4 전후) 전에 반드시 사용자와 다시 확인할 것** — `.claude/ +question.md`에 같은 항목 등재됨. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index d048320..392ef06 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -296,7 +296,7 @@ State 핸들로 넘기고 `:Get()`을 실제로 읽을 때만 계산)은 `base/b `Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아 Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는 store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브 -`base/additional-primitives.md`의 "Blocker" 절이 다룸(State 개발과 같은 +`base/blocker-plan.md`가 다룸(State 개발과 같은 마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던 초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — `archive/batch-rejected.md` 참고. diff --git a/.claude/question.md b/.claude/question.md index 2c54ff4..d5d9e32 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -16,7 +16,7 @@ 같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 여러 서브에이전트 조사 + 사용자와 라이브 논의로 계속 수렴 중. **2026-08-07 문서 정리에서 확정/기각된 항목은 `research/additional-primitives-plan.md`에서 -분리됨**: Effect/Blocker → `base/additional-primitives.md`, Batch → +분리됨**: Blocker → `base/blocker-plan.md`, Effect → `base/effect-plan.md`, Batch → `archive/batch-rejected.md`, Context(+레이어드 Store) → `archive/ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 남김. @@ -32,14 +32,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 상세는 `research/additional-primitives-plan.md`(이제 이 주제 전용). - **Effect가 Observer의 변형(`state:Effect()`)인지, 완전히 독립된 free function인지 — 신규, 2026-08-07 문서 정리 세션에서 발견.** `base/ - additional-primitives.md`가 지금까지의 조사대로 Effect를 "재실행 없는 + effect-plan.md`가 지금까지의 조사대로 Effect를 "재실행 없는 독립 free function"으로 서술해뒀지만, 사용자가 직접 `state:Effect()` 형태(=Observer에 "확정 정리" 계약만 추가된 변형)로 기억하고 있어서 확인이 필요함. 관련 하위 질문: `state:Observer(fn)`가 생성 시점에 `fn`을 즉시 1회 실행하는지도 현재 문서 어디에도 명시돼 있지 않음(Effect는 "즉시 1회 실행"이 스펙에 있음 — 이 부분만 보면 둘이 겹쳐 보이는 이유). **임의로 결론내지 않고 열어둠** — 구현 착수(M3~M4 전후) 전에 확인 필요, - 상세는 `base/additional-primitives.md`의 "미해결" 절. + 상세는 `base/effect-plan.md`의 "미해결" 절. - Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단(`research/ additional-primitives-plan.md` "빈 자리 아닌 것" 절). @@ -200,7 +200,8 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** | Modifier(정적 merge, immutable 체이닝, State 필드 지원) | `base/modifier-plan.md` | | 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Merge`) | `base/component-composition-plan.md` | | 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | -| Blocker(값 기반 emit 지연/합치기), Effect(leaf 죽음에 확정 정리 — 단 Observer와의 관계는 위 0번 열린 질문 참고) | `base/additional-primitives.md` | +| Blocker(값 기반 emit 지연/합치기) | `base/blocker-plan.md` | +| Effect(leaf 죽음에 확정 정리 — 단 Observer와의 관계는 위 0번 열린 질문 참고) | `base/effect-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` | diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 0a53e2f..e0a9727 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -2,7 +2,7 @@ **상태**: research — 사용자와 라이브 논의로 대부분 수렴(2026-08-06~07), **2026-08-07 문서 정리에서 확정/기각된 항목을 분리**: Effect/Blocker → -`base/additional-primitives.md`, Batch(lexical) → `archive/ +`base/blocker-plan.md`/`base/effect-plan.md`, Batch(lexical) → `archive/ batch-rejected.md`, Context(+레이어드 Store 대안) → `archive/ context-rejected.md`. 이 문서에는 **아직 완전히 열려있는 것 하나만** 남음 — 키 기반 동적 컬렉션 재조정. 사용자가 "작업 전에 모든 정의를 마치고 @@ -30,11 +30,11 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 | 후보 | 판정 | 현재 위치 | |---|---|---| | 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** — 아직 열려있음 | 이 문서(아래) | -| Effect(leaf 죽음에 확정 정리) | 채택, 단 Observer와의 관계는 미해결 | `base/additional-primitives.md` | -| Blocker(값 기반 emit 지연/합치기) | **채택** — Batch의 대안 | `base/additional-primitives.md` | +| Effect(leaf 죽음에 확정 정리) | 채택, 단 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/additional-primitives.md`(Effect 절에 근거만 인용) | +| Observer에 cleanup 반환 계약 추가 | **기각** — 클로저 업밸류로 이미 충분 | `base/effect-plan.md`(근거만 인용) | | Untrack/Peek | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | | Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | 아래 "빈 자리 아닌 것" 절 | | Error Boundary | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | @@ -189,7 +189,7 @@ Slot:Add(element, index?) -- 기존 그대로, Extract로 뺀 것도 다시 - **quadnomicon 에세이**: - "왜 lexical Batch를 기각하고 대신 값 기반 Blocker를 택했는가" — - `archive/batch-rejected.md`와 `base/additional-primitives.md`의 + `archive/batch-rejected.md`와 `base/blocker-plan.md`의 Blocker 절을 나란히 비교. - "왜 Context가 없는가" — `archive/context-rejected.md` 참고. - "왜 push-invalidate/pull-recompute 설계가 laziness와 재계산 방지를 @@ -204,7 +204,7 @@ Slot:Add(element, index?) -- 기존 그대로, Extract로 뺀 것도 다시 유연한 구조" — 명시적 의존성 선언(`:With`) 위에서도 실제 계산은 조건부로 일부만 쓸 수 있다는 팁. - "Blocker 사용 가이드" — 파이프라인 최종 연산 지점에 배치, **네스팅 - 금지를 강하게 명시**(`base/additional-primitives.md`의 "재진입" 절 + 금지를 강하게 명시**(`base/blocker-plan.md`의 "재진입" 절 참고, 문서화 시 최우선 강조 항목). - "여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산을 피하는 파이프라인/업데이트 순서 팁"(Blocker를 안 쓰는 단순 케이스용 보조 팁, diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index a5f651b..0be0516 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -135,7 +135,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 실제 일어나는 derived state)에 배치하는 게 원칙이라는 것, **네스팅 금지를 최우선으로 강조**(겹치는 배치는 각자 새 `Blocker`를 만들 것 — 안 지키면 조용히 잘못된 시점에 조기 해제되는 원인 추적 어려운 버그로 - 이어짐) — `base/additional-primitives.md`의 "Blocker" 절 + 이어짐) — `base/blocker-plan.md` 19. 여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산/재대입을 피하는 파이프라인/업데이트 순서 팁(Blocker를 안 쓰는 단순 케이스용 보조 팁) — `research/additional-primitives-plan.md` "문서화 백로그" 절 @@ -205,7 +205,7 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) - Effect가 `state:Effect()`로 Observer를 확장하는 형태인지, 완전히 독립된 - free function인지 (`base/additional-primitives.md`의 "미해결" 절) + free function인지 (`base/effect-plan.md`의 "미해결" 절) 이 항목들은 `.claude/question.md`에도 이미 열린 질문으로 잡혀있음 — 여기선 "확정 전엔 문서화 대상 아님"이라는 표시만 겸함. diff --git a/CLAUDE.md b/CLAUDE.md index f33c44d..7f4e13e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -712,13 +712,19 @@ M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. 표면 없이 기존 per-instance weak-table 유틸(`base.perInstanceState`) 재사용만으로 충분하다는 점을 추가. 4. **`additional-primitives-plan.md`를 4갈래로 분리**: 확정된 `Blocker`/ - `Effect`는 새 `base/additional-primitives.md`로 승격(Blocker는 - State와 같은 마일스톤에서 개발하기로 해서 `store-semantics.md`에 - 교차 참조 추가, `ROADMAP.md` M3에도 체크박스 반영). 기각된 `Batch` - (lexical block)와 `Context`(+대안이던 레이어드 Store)는 각각 - `archive/batch-rejected.md`/`archive/context-rejected.md`로 분리. - `research/additional-primitives-plan.md`엔 아직 실제로 열려있는 - 것(키 기반 동적 컬렉션 재조정) 하나만 남김. + `Effect`는 각각 새 `base/blocker-plan.md`/`base/effect-plan.md`로 + 승격(Blocker는 State와 같은 마일스톤에서 개발하기로 해서 + `store-semantics.md`에 교차 참조 추가, `ROADMAP.md` M3에도 체크박스 + 반영). 기각된 `Batch`(lexical block)와 `Context`(+대안이던 레이어드 + Store)는 각각 `archive/batch-rejected.md`/`archive/context-rejected.md`로 + 분리. `research/additional-primitives-plan.md`엔 아직 실제로 열려있는 + 것(키 기반 동적 컬렉션 재조정) 하나만 남김. **[같은 날 바로 정정]** + 처음엔 Blocker/Effect를 `base/additional-primitives.md` 한 파일로 + 합쳐 승격했으나, 사용자가 "State 볼 때 Effect까지 볼 필요는 없다, + 기존 프리미티브당 1파일 컨벤션(`modifier-plan.md`/`slot-plan.md`류)에 + 맞지 않는다"고 지적해 바로 두 파일로 재분리함 — Blocker는 + Store/State와 밀접해 교차 참조가 필요하지만 Effect는 완전히 독립된 + 요소라 애초에 같은 파일일 이유가 없었음. 5. **archive 제목 컨벤션을 둘로 분화** — 기존 `[역전됨]`(한 번 확정했다가 뒤집힌 것, `store-source-proxy-reversed.md`/`ref-phase-option-reversed.md`)과 새로 생긴 `[기각됨]`(확정한 적 없이 후보였다가 채택 안 된 것, @@ -740,7 +746,7 @@ M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. 명시). 관련 하위 질문으로 `state:Observer(fn)`가 생성 시 `fn`을 즉시 1회 실행하는지도 문서 어디에도 명시돼 있지 않음이 이번에 드러남(Effect는 "즉시 1회 실행"이 스펙에 명시돼 있어 이 부분만 보면 둘이 겹쳐 보임). -`base/additional-primitives.md`의 "미해결" 절과 `.claude/question.md` +`base/effect-plan.md`의 "미해결" 절과 `.claude/question.md` 0번에 반영 — 구현 착수(M3~M4 전후) 전에 반드시 재확인할 것. **다음 세션이 할 일**: 안 바뀜(위 2026-08-06 네 번째 세션 절 "다음 세션이 diff --git a/ROADMAP.md b/ROADMAP.md index b2a7bc2..751eaf4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,7 +64,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Source.luau`/`State.luau`/`Store.luau` - [ ] `store.key` dot-access 타입 추론 확인 -- [ ] `Blocker.luau`(`base/additional-primitives.md` 참고 — 여러 Source를 +- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) - [ ] mock 대상 테스트 From 18d694236664b1420ab9270f33e374e8844b5a15 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 15:45:07 +0900 Subject: [PATCH 34/62] =?UTF-8?q?decide(base):=20Modifier=20Override/Peek/?= =?UTF-8?q?isState=20=ED=99=95=EC=A0=95,=20Apply=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=9B=90=EC=B9=99=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modifier.Merge를 Override로 개명, 동작(필드별 baked 값 교체) 확정 — props.Modifier 단일 슬롯용 특수 상황으로 문서화 범위를 좁히고 Apply를 기본 관용구로 유도 - :Peek<>(key): T|State|nil 필드 읽기 접근자, isState(x) 판별 predicate(weak-key 레지스트리 기반) 신설 - Apply를 mutable로 바꾸는 방안과 "Apply 경계에서만 clone" 절충안 모두 검토 후 기각 — immutable 전체 clone 유지 - Apply vs Override 판단 기준을 "계산 의존성 유무"로 명문화, FuncSource 기각 사유를 기존 확정 원칙에서 연역해 문서화 - ROADMAP/question.md/pre-implementation-audit.md/documentation-content-map.md 등 교차 참조 전부 갱신 Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 2 +- .claude/base/bind-system-plan.md | 37 ++++ .claude/base/component-composition-plan.md | 14 +- .claude/base/modifier-plan.md | 187 +++++++++++++++++- .claude/question.md | 9 +- .claude/research/documentation-content-map.md | 6 +- .claude/research/pre-implementation-audit.md | 43 ++-- CLAUDE.md | 96 +++++++++ ROADMAP.md | 6 +- 9 files changed, 364 insertions(+), 36 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index 2cbd6a9..fbdd944 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -31,7 +31,7 @@ | `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 패키지 경계까지 확정 | -| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정, getter 이름만 남음 | +| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Override`(구 `Merge`) 값 결합+성능 기준, `: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)과 함께 개발. 메커니즘+이름 확정 | diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 1b524d1..0a678b7 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -1121,6 +1121,43 @@ Instance 참조 등 제한된 프리미티브 집합, 테이블 등 복합 타 확인 전 소견일 뿐 — `.claude/question.md`에 반영, 다음 세션에서 사용자 판단 필요. +## `isState(x): boolean` — State/Source 판별 predicate (2026-08-07 다섯 번째 세션) + +**배경**: `base/modifier-plan.md` 9번 절의 `:Peek<>(key): T|State|nil` +(Modifier 필드를 확정하지 않고 raw 그대로 읽는 접근자)이 나오면서, 사용자 +코드가 그 결과를 State/plain으로 분기하려면 판별 수단이 필요해짐 — 같은 +필요가 실은 새로 생긴 게 아니라 Modifier의 함수형 setter(`modifier-plan.md` +4-1번, "현재 필드가 State냐 plain이냐"로 동작이 갈림)가 지금까지도 내부적으로 +풀어야 했던 문제인데 그 판별 방법 자체가 문서에 명시된 적이 없었음 — 이번에 +`isState`로 명문화하며 그 구멍도 같이 메움. + +**Source도 같이 잡힘, 별도 `isSource` 불필요** — Source가 State를 구조적으로 +만족(위 "Source가 State를 만족함" 관련 내용은 `base/store-semantics.md` +참고)하므로, `isState(source) == true`가 자연스러운 동작이고 그걸로 충분함. + +**구현: weak-key 레지스트리, duck-typing 아님.** + +``` +local registry = setmetatable({}, {__mode = "k"}) +-- State/Source를 만드는 모든 생성 지점(Source(...), :With(...), :Compute(fn) 등)에서: +registry[newHandle] = true +-- predicate: +local function isState(x) + return registry[x] == true +end +``` + +**duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 +이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의 +값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를 +가진 `T`에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키 +인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한 +엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. +weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 +같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 State/ +Source가 GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 +레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격). + ## 남은 열린 질문 (`.claude/question.md`에도 취합) 이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index bada990..198fdff 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -238,25 +238,29 @@ Fusion/Vide(named prop 전달, `[Children]`류 예약 키)가 서로 다른 이 가야 하는지는 저작자가 자기 코드에 뭐라고 쓰느냐로 완전히 결정됨(자동 전파가 없기 때문에 성립하는 단순함). -### 3. 여러 modifier를 하나로 합치는 공개 유틸 필요 — `Modifier.Merge`(가칭) +### 3. 여러 modifier를 하나로 합치는 공개 유틸 필요 — `Modifier.Override`(가칭, 2026-08-07 다섯 번째 세션에서 `Merge`→개명, 동작 확정) caller가 named parameter 하나에 여러 modifier를 몰아넣고 싶을 때 (`Frame{modifier1, modifier2}`의 컴포넌트판)를 위해, 기존 flatten 규칙(배열 순서상 나중 것이 필드 단위로 이김, `modifier-plan.md` 2번)을 그대로 재사용하는 -결합 함수를 공개 API로 노출: `Modifier.Merge(mod1, mod2, ...) -> Modifier`. +결합 함수를 공개 API로 노출: `Modifier.Override(mod1, mod2, ...) -> Modifier`. 새 병합 규칙이 아니라 이미 확정된 flatten을 함수로 한 번 더 꺼내 쓸 수 있게 하는 것뿐 — **사용자 요청**("modifier를 합칠 방법도 존재한다면 좋을것 -같아"). `MyComp { Modifier = Modifier.Merge(theme, override) }` → 컴포넌트 +같아"). `MyComp { Modifier = Modifier.Override(theme, override) }` → 컴포넌트 내부는 항상 이미 합쳐진 단일 값만 받으므로 컴포넌트 저작자가 배열 처리를 신경 쓸 필요 없음. Ref는 필드 충돌 개념이 없어 이 문제 자체가 없음(여러 Ref를 받으면 그냥 전부 실행하면 됨, `modifier-plan.md` §4-2) — 별도 결합 -유틸 불필요. +유틸 불필요. **정확한 동작(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.Merge` + 컴포넌트 경계용 `props.Modifier`/`props.Ref` 필드명, `Modifier.Override` 함수명은 전부 가칭. (`GetSource` 계열 접근자는 위 3번 정정으로 아예 불필요해짐 — `store.key`가 직접 Source를 반환하므로 별도 접근자 자체가 없음.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/ diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 476f8be..0e5894e 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -3,11 +3,14 @@ **상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable 값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. **Getter는 별도로 안 만들기로 확정(2026-08-06 후속 세션)** — 아래 "4. -Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. Modifier가 컴포넌트 경계를 어떻게 -통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로 -**[정정] `research/component-composition-plan.md`는 2026-08-04 세션에 수렴 -완료돼 `base/component-composition-plan.md`로 승격됨 — 이 문서는 -"Modifier 값 자체가 어떻게 동작하는가"만 다룸. +Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. **팩토리 함수 체이닝 +(`:Apply`), 값 결합(`Override`, 구 `Merge`), 필드 읽기(`:Peek`)+판별 +(`isState`)은 2026-08-07 세션들에 걸쳐 확정 — 8/9번 절 참고, 한 줄 요약은 +`Apply`="변경을 수행", `Override`="이미 계산된 다른 mod를 합침".** Modifier가 +컴포넌트 경계를 어떻게 통과하는지(named parameter로 전달, multi-root +개념 폐기)는 별개 문제로 **[정정] `research/component-composition-plan.md`는 +2026-08-04 세션에 수렴 완료돼 `base/component-composition-plan.md`로 +승격됨 — 이 문서는 "Modifier 값 자체가 어떻게 동작하는가"만 다룸. ## 문제 @@ -247,6 +250,177 @@ setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 합성하도록 구현. 따라서 **`Apply`는 Modifier 필드 이름으로 예약됨**(실제 스타일 프로퍼티 이름과 겹칠 일은 거의 없어 보이지만 문서화 필요). +**권장 관용구, 문서화 필요(2026-08-07 다섯 번째 세션)**: 특정 modifier를 +계속 변형/보정하고 싶은 경우(스타일 프리셋, 커링된 팩토리 등)엔 항상 +`Apply`를 기본 선택지로 유도할 것 — 아래 9번의 `Override`는 "이미 따로 +만들어진 modifier 값 두 개 이상을 합쳐야 하는" 경우로만 좁혀서 문서화(용도 +구분 절 참고). + +**`Apply`는 `factory(self)` 그 이상도 이하도 아님 — 특별한 계약 없음, +문서화 필요.** `factory` 내부가 `Peek`으로 State를 기대했는데 없다고 +`error`를 던지거나, 특정 조건에서 그냥 죽어버리는 것도 `Apply` 입장에선 +아무 문제 아님 — `Apply`는 `factory`가 뭘 하든 관여하지 않는 순수 함수 +호출 sugar일 뿐이라, 유효성 검사/기본값 처리/에러 핸들링은 전부 `factory` +저작자 책임. 문서에는 "`:Apply(f)`는 `f(mod)`를 체이닝 문법으로 쓴 것뿐, +Apply 자체가 뭔가를 검증하거나 보장해준다고 오해하지 말 것"을 명시. + +### 9. Modifier 결합 — `Modifier.Override(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` → **`Override`로 확정**(사용자 제안). "Merge"는 +중립적 합침을 암시하지만 실제 동작은 명시적으로 나중 인자가 이기는 +"덮어쓰기"라, 이름이 의미를 정직하게 반영해야 함 — `component-composition-plan.md`의 +참조도 이번에 같이 갱신함. + +**용도를 좁게 문서화할 것 — "진짜 합칠 필요가 있는 경우"로 한정 +(2026-08-07 다섯 번째 세션, 사용자 강조).** `Override`는 범용 조합 +도구가 아니라 `Frame{mod1, mod2}`의 컴포넌트 경계판, 그 이상도 이하도 +아님 — 이게 없으면 `props.Modifier` 같은 단일 슬롯에 여러 독립 modifier +값을 넣을 방법이 아예 없어지므로 프리미티브로 남겨두는 것뿐. **"특정 +modifier를 계속 바꿔나가고 싶다"는 요구는 `Override`가 아니라 위 8번 +`Apply`로 풀도록 유도** — 간결한 커링/일급 함수 전달이 기본 관용구가 +되도록, API 문서에서 `Override`를 "값 두 개 이상을 합쳐야 하는 특수 +상황"으로만 소개하고 스타일 변형/보정의 기본 진입점으로는 절대 먼저 +보여주지 않을 것. + +**동작 = 기존 flatten을 함수로 노출한 것, 새 규칙 없음.** +`Modifier.Override(mod1, mod2, ...)`는 뒤 인자가 필드 단위로 이긴다(2번 +절 "배열 순서" 규칙과 동일). 구현은 단순 필드별 덮어쓰기 — 특별한 +State/함수 분기가 필요 없음: setter가 이미 호출 시점에 함수를 즉시 +실행하고 State 필드는 즉시 `:Compute`로 파생시켜 저장하므로(4번/4-1번), +Modifier가 들고 있는 모든 필드는 항상 "그 시점에 이미 완전히 처리된 +(baked) 값"임 — `Override`는 그 baked 값을 필드별로 그대로 교체할 뿐. + +**경고, 반드시 문서화**: baked 값 교체는 그 필드에서 파생된 다른 필드에 +소급 반영되지 않는다. 예: `Boldify`가 `Font` 필드를 읽어(`Peek`, 아래 +참고) `FontWeight`를 계산해 넣어둔 modifier를, 나중에 `Font`를 바꾸는 +다른 modifier와 `Override`로 합치면 `Font`는 새 값으로 바뀌지만 +`FontWeight`는 예전 `Font` 기준으로 계산된 채 그대로 남는다 — 사용자 +실수 범주지만 조용히 틀린 결과가 나오는 케이스라 API 문서(경고 박스)로 +명시 필요. `A:Override(B)`와 `B:Override(A)`가 다른 결과를 낸다는 순서 +의존성도 같은 경고 박스에 같이 명시. + +**용도 구분 — `Apply` vs `Override`, 둘 다 유지, 서로 대체 안 함**: +한 줄로 요약하면 **`Apply`는 "특정 대상에 대해 변경을 수행한다", `Override`는 +"특정 대상에 이미 계산된(baked) 다른 mod를 합친다"** — 문서화 시 이 한 +문장을 그대로 핵심 구분 기준으로 앞세울 것(2026-08-07 다섯 번째 세션, +사용자 정리). 재사용 가능한 스타일 "변형"(팩토리, 파라미터화 가능)은 +`Apply`, 독립적으로 이미 만들어진 modifier "값" 두 개 이상을 한 슬롯에 +밀어넣어야 하는 경우(주로 컴포넌트 경계)는 `Override`. + +**9-1. 판단 기준을 "이질적/동질적"이 아니라 "계산 의존성 유무"로 명시할 것, +`Apply`를 mutable로 바꾸는 방안은 기각 (2026-08-07 다섯 번째 세션 후속)** + +**동기**: `Apply` 체이닝이 호출마다 clone을 만들기 때문에, 항목 수천 개짜리 +리스트 UI처럼 무거운 Modifier를 대량으로 재생성하는 상황에서 이 clone +비용이 누적되는 게 아닌지 사용자가 우려 — 대안으로 (a) `Apply`/setter를 +아예 mutable로 바꾸는 방안, (b) `Override`를 "여러 값을 합칠 특수 상황"이 +아니라 "성능 최적화 수단"으로 승격하는 방안을 검토. + +**(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하는 지금 방식이 버그 클래스를 균일하게 없애는 유일한 방법 — +확정. + +**(b) 판단 기준 — "동질적 vs 이질적 프로퍼티"가 아니라 "필드 간 계산 +의존성 유무"로 명시.** 사용자가 처음엔 "동질적(폰트 굵기 보정처럼 연관된 +속성끼리)은 `Apply`, 이질적(배경/텍스트/위치처럼 무관한 속성끼리)은 +`Override`"로 구분을 제안했으나, 실제 기준은 주제의 이질성 자체가 아니라 +**한쪽이 다른 쪽의 이미 baked된 값을 읽어야 하는가(`Peek`으로 데이터가 +흘러가는가)**임 — 이질적으로 보여도 계산 의존성이 있으면 `Apply`가 +맞고(예: "배경색에 맞춰 텍스트 명도를 자동 보정" — 배경/텍스트라는 이질적 +주제인데도 의존성이 있어 `Peek`+`Apply`가 필요), 반대로 동질적으로 보여도 +서로 완전히 독립이면(예: 여러 개의 `FontSize` 프리셋 중 하나를 통째로 +갈아끼우는 경우) `Override`도 무방함. `Override`는 필드 단위 raw 교체일 +뿐 `Peek`으로 값을 읽어 다른 필드에 반영하는 데이터 흐름이 아예 없으므로 +(위 "동작" 절), 계산 의존성이 있는 조합엔 애초에 못 씀 — 이게 진짜 판별 +기준. 문서에는 "이질적/동질적"이라는 표면적 구분 대신 이 기준으로 적을 것. + +**실제 최적화 권장 패턴**: 계산 의존성이 없고 재사용 가능한 조각(예: +배경 스타일 하나, 텍스트 스타일 하나, 레이아웃 위치 하나 — 각각 서로 +다른 서브시스템/모듈에서 한 번만 만들어지는 값)은 **모듈 상수/한 번만 +생성한 값으로 만들어두고, 인스턴스마다 `Override`로 결합**하는 게 +`Apply` 체인으로 매번 처음부터 다시 파생시키는 것보다 저렴함 — 조각 자체를 +매번 재계산 안 해도 되고, `Override`는 필드별 단순 복사 한 번으로 끝나서 +여러 단계 clone이 누적되는 `Apply` 체인보다 쌈. **주의**: 이건 "`Override`가 +내부적으로 값을 캐싱해준다"는 뜻이 아님 — `Override` 자체엔 캐싱/메모이제이션 +같은 새 메커니즘이 전혀 없고(순수 필드 복사), "캐싱"은 그냥 사용자가 조각 +Modifier 값을 변수/모듈 상수로 만들어 재사용하는, 기존에도 항상 가능했던 +평범한 값 재사용일 뿐 — 라이브러리에 새 캐싱 레이어를 추가하는 게 아니라는 +점을 문서에서 분명히 할 것(라이브러리 차원의 자동 메모이제이션은 지금 +검토 대상 아님 — 실제로 필요하다고 확인되면 그때 별도로 논의). + +**문서 배치**: 초심자 문서엔 `Override`를 아예 안 보여주고(위 "용도를 좁게 +문서화" 절), 이 "언제 `Apply` vs `Override`, 성능 기준" 절 전체는 api/심화 +문서 전용 — `research/documentation-content-map.md`의 modifier-plan.md +분류에 반영 완료. + +**미검토로 남긴 것**: `Apply` 체인이 실측으로 병목이라고 확인되면 그때 +"unsafe/fast-path mutable 빌더" 같은 별도 opt-in을 검토할 수 있으나, 지금은 +근거 없는 선제 최적화라 설계하지 않음 — CLAUDE.md의 "드문 오용/가상 미래 +요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 동일. + +**`:Peek<>(key): T | State | nil`** — Modifier 필드를 확정하지 +않고 그대로 읽는 접근자. 이름을 `Get`이 아니라 `Peek`로 정한 이유: 이 +프로젝트 전역에서 `State:Get()`은 "확정한다"(pull + recompute + 최종값 +반환)는 의미로 이미 자리잡았는데, Modifier의 읽기는 정반대(들고 있는 +그대로, State면 State 핸들 그대로) — 같은 동사를 반대 의미로 쓰면 안 +되므로 다른 이름 필요. 반환 타입을 `T`로 확정해 돌려주지 않고 +`T|State|nil` raw 그대로 노출하는 이유: 4-1번 절의 함수형 setter가 +받는 `old` 인자와 정확히 같은 원칙("현재 저장된 그대로 넘김") 재사용 — +자동으로 `:Get()`해서 `T`로 확정해버리면 반응성이 조용히 끊기는데 +타입엔 그 사실이 안 드러나서 위험함. `.RealValue.Font` 같은 별도 +인덱싱 표면은 기각 — 이미 `__index`가 필드 setter 합성용으로 예약돼 +있는데(`Apply`가 첫 예약 사례) 또 다른 프록시 네임스페이스를 얹으면 +setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방식과 별 +차이 없음. + +`:Peek`는 팩토리 함수(`Apply`에 넘기는 콤비네이터) 안에서 쓰는 게 +전형적 — "이 modifier가 현재 어떤 상태인지 보고 그걸 바탕으로 값을 +계산"한다는 문맥이 명확해서 오해 소지가 적음. Peek 결과가 State일 때 +그걸 즉시 읽어 스냅샷으로 쓸지, State 핸들을 그대로 물고 가 `:Compute`로 +새 파생 State를 만들지는 유저 선택 — 전자는 이후 원본이 바뀌어도 반영 +안 되는 캐비엇이 있지만, 이건 quad가 대신 풀어줄 문제가 아니라 문서화 +(경고)로 충분(이미 있는 "`Get()` 결과 캐싱 금지" 캐비엇과 같은 클래스). + +**`isState(x): boolean` 필요 — `base/bind-system-plan.md`에 정의**. +`Peek`가 raw union을 돌려주므로 사용자 코드가 State/plain을 분기하려면 +판별 수단이 필요함(Source가 State를 구조적으로 만족하므로 `isState`가 +Source도 같이 잡아줌 — 별도 `isSource` 불필요). 상세 근거/구현 방식은 +`bind-system-plan.md`의 `isState` 절 참고 — 요지만: duck-typing 대신 +weak-key 레지스트리 기반, 그리고 이 판별 로직 자체는 새로 만드는 게 +아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로 +승격하는 것뿐. + ## 열린 질문 (`.claude/question.md`에도 취합) - **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter @@ -256,3 +430,6 @@ setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 `base/component-composition-plan.md`에서 해소됨**(named parameter로 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 다루는 "값 자체의 동작"과는 별개 문제였다는 점만 참고로 남김. +- `Override`/`Peek`/`isState` — 동작은 위 9번 절에서 확정, 정확한 이름은 + 다른 가칭들과 마찬가지로 `.claude/question.md`의 용어 정리 라운드까지 + 잠정. diff --git a/.claude/question.md b/.claude/question.md index d5d9e32..da44a8e 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -87,6 +87,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션 재조정" 절 참고. +- **`Override`/`Peek`/`isState`(3순위, 사소함, 2026-08-07 다섯 번째 + 세션 추가)**: Modifier 결합 유틸(구 `Merge`)과 필드 읽기 접근자, State/ + Source 판별 predicate 세 개의 이름 — 동작은 전부 확정(`base/ + modifier-plan.md` 9번, `base/bind-system-plan.md`의 `isState` 절), + 이름만 다른 가칭들과 같이 용어 정리 라운드에서 재검토. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ @@ -197,8 +202,8 @@ context-rejected.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 필드 지원) | `base/modifier-plan.md` | -| 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Merge`) | `base/component-composition-plan.md` | +| Modifier(정적 merge, immutable 체이닝, State 필드 지원, `Apply`/`Override`/`Peek`/`isState`) | `base/modifier-plan.md` | +| 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Override`) | `base/component-composition-plan.md` | | 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | | Blocker(값 기반 emit 지연/합치기) | `base/blocker-plan.md` | | Effect(leaf 죽음에 확정 정리 — 단 Observer와의 관계는 위 0번 열린 질문 참고) | `base/effect-plan.md` | diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 0be0516..c05bcbb 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -74,7 +74,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 ### component-composition-plan.md / module-lifecycle-plan.md - 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `props.Modifier`/`props.Ref` named parameter 경계 전달 / `InitRoblox(Module)` 팩토리 초기화 -- api: State(파생, 읽기전용) vs Source(원본, 쓰기가능) 경계 요약(→심화) / Slot 반환 컴포넌트는 Modifier/Ref 파라미터 미선언 / `Modifier.Merge(mod1, mod2, ...)` 유틸 / Bind는 유일 슬롯(재호출 no-op, 충돌 에러, →심화) / `:With`/`:Compute`로 파생 State 생성 시그니처 / 모듈 싱글톤 스코프 +- api: State(파생, 읽기전용) vs Source(원본, 쓰기가능) 경계 요약(→심화) / Slot 반환 컴포넌트는 Modifier/Ref 파라미터 미선언 / `Modifier.Override(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 네이밍 미정 등 열린 질문 메모 @@ -86,8 +86,8 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 ### modifier-plan.md / slot-plan.md - 초심자: Modifier 기본 체이닝+merge 우선순위 규칙 실제 예시 / Slot 기본 개념(children 배열)+클래스가 슬롯 받는 방법(Named Slot 없음) / 마운트된 slot 재마운트 시 즉시 throw -- api: Setter가 리터럴/변환 함수 둘 다 받음(→심화: getter 없는 이유) / 필드가 State일 수 있는 4가지 조합 표(→심화: 반응성 유지/끊김 이유) / `Modifier.Rounded(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) -- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) +- api: Setter가 리터럴/변환 함수 둘 다 받음(→심화: getter 없는 이유) / 필드가 State일 수 있는 4가지 조합 표(→심화: 반응성 유지/끊김 이유) / `Modifier.Rounded(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) / `:Apply(factory)` 기본 체이닝 관용구(→심화: 언제 `Apply` vs `Override`인지 성능 기준) / `:Peek<>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유) +- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) / **왜 `Apply`가 기본이고 `Override`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선) - 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정 - skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index c3302cc..5d546a2 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -340,24 +340,29 @@ API"를 얹어야 할 때 Slot의 "own한 것만 CRUD 대상" 불변식 자체 `existing-instance-bind-plan.md`에 한 줄 추가해둘 것 — 기존 "Modifier flatten과 긴장" 캐비엇 옆에 병기. -### 2-5. `Modifier.Merge` 시 서로 다른 클래스의 Modifier가 섞이는 게 허용되는지 미정 +### 2-5. `Modifier.Override`(구 `Merge`) 시 서로 다른 클래스의 Modifier가 섞이는 게 허용되는지 — **런타임은 해소, 타입 레벨은 여전히 미정** -**위치**: `base/modifier-plan.md` "2. Merge 우선순위" + `base/ -component-composition-plan.md` 3번(`Modifier.Merge`). +**위치**: `base/modifier-plan.md` "2. Merge 우선순위" + 9번(`Override`) + +`base/component-composition-plan.md` 3번. -**문제**: `question.md`가 언급하는 `DI.FrameModifier`류 "클래스별 타입 -프리픽스"를 보면 Modifier는 대상 인스턴스 클래스별로 타입이 나뉜다. merge는 -"필드명 기준, 나중 게 이김"이라고만 확정돼 있는데 — `Modifier.Merge`의 -가장 그럴듯한 실사용 시나리오가 바로 "공통 테마 Modifier(여러 GuiObject -클래스에 걸친 공통 필드) + 클래스별 override Modifier"를 합치는 것이다. -두 Modifier가 정적으로 다른 타입이면 `Modifier.Merge` 시그니처가 애초에 -타입 에러로 막아주는지(같은 제네릭 파라미터만 merge 허용), 아니면 필드명만 -보고 런타임에 섞이는 순수 데이터 레이어라 다른 클래스끼리도 그냥 합쳐지는 -지가 전혀 명시돼 있지 않다. +**[2026-08-07 다섯 번째 세션 갱신] 런타임 동작은 이제 명확함**: `modifier-plan.md` +9번에서 `Override`가 "필드별 raw 덮어쓰기"로 확정됐고, "Modifier는 핸들러 +계층을 모름 — 순수 데이터 merge 레이어"(1번 절) 원칙도 이미 있었으므로, +**런타임 레벨에서는 대상 클래스가 다른 Modifier끼리 `Override`해도 막을 +이유가 없음**(필드명만 보고 그대로 덮어쓸 뿐 — Luau 타입은 런타임에 +강제되지 않는다는 점은 `store-semantics.md`에도 이미 명시된 전제). -**제안**: Modifier가 target 클래스별 제네릭 타입(`Modifier` 등)인지, -있다면 계층 구조(공통 base + 클래스별 확장)가 뭔지, `Modifier.Merge`가 -이걸 타입으로 강제하는지 최소 한 문장으로 확정할 것. M7 착수 시. +**여전히 미정인 것 — 타입 레벨**: Modifier가 target 클래스별 제네릭 +타입(`Modifier` 등)이라면, `Modifier.Override(mod1: Modifier, +mod2: Modifier): Modifier`처럼 같은 `T`만 받도록 타입으로 강제할지, +아니면 공통 base 타입(여러 GuiObject 클래스에 걸친 공통 필드)과 클래스별 +확장 사이의 계층 구조를 별도로 두고 `Override`가 그 계층을 넘나들 수 +있게 할지는 아직 결정된 바 없음 — "공통 테마 Modifier + 클래스별 override +Modifier를 합친다"는 시나리오가 `Override`의 가장 그럴듯한 실사용 +예시라 이 타입 설계가 실제로 막히면 바로 걸릴 문제. + +**제안**: Modifier의 클래스별 typed 생성자 계층(2-8번과 같은 지점) 설계 +시 `Override`의 제네릭 시그니처도 같이 확정할 것. M7 착수 시. ### 2-6. Modifier 필드에 State/Source를 인자로 넘기는 케이스가 세터 표에서 빠짐 @@ -377,10 +382,10 @@ component-composition-plan.md` 3번(`Modifier.Merge`). ### 2-7. 여러 Ref를 하나의 named parameter로 넘길 때 nested-array flatten 여부 불명 -**위치**: `base/component-composition-plan.md` 3번(`Modifier.Merge`) vs -"Ref는... 별도 결합 유틸 불필요" 문장. +**위치**: `base/component-composition-plan.md` 3번(`Modifier.Override`, +구 `Merge`) vs "Ref는... 별도 결합 유틸 불필요" 문장. -**문제**: Modifier는 여러 개를 합치려면 `Modifier.Merge`가 명시적으로 +**문제**: Modifier는 여러 개를 합치려면 `Modifier.Override`가 명시적으로 필요한데, 바로 다음 문장은 Ref는 "여러 Ref를 받으면 그냥 전부 실행하면 됨 — 별도 결합 유틸 불필요"라고 한다. `props.Ref = {ref1, ref2}`처럼 배열을 넘기면 리프 디스패처가 그 중첩 배열을 재귀적으로 펼쳐서 각 Ref를 @@ -402,7 +407,7 @@ component-composition-plan.md` 3번(`Modifier.Merge`). 것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴 재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게 결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch, -`Modifier.Merge`, `State` 차단)엔 이 클래스별 타입 생성 작업이 +`Modifier.Override`, `State` 차단)엔 이 클래스별 타입 생성 작업이 전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음. **제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier diff --git a/CLAUDE.md b/CLAUDE.md index 7f4e13e..ada599e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -740,6 +740,102 @@ M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. `TweenInfo.new(...)` 포지셔널 생성자가 읽기 어렵다는 문제의식, 소견은 편의 필드 쪽이지만 확정 아님, 나중 논의 대상으로만 남김. +## 2026-08-07 다섯 번째 세션 — Modifier 결합(`Override`)/읽기 접근자(`Peek`)/`isState` 확정, FuncSource 기각 사유 문서화 + +**출발점**: `:Apply`(4번째 세션 신설)처럼 Modifier에 더 있으면 좋을 게 +있는지 사용자가 제기 — `Merge`류 결합 유틸의 우선순위 문제, 그리고 +Modifier 자신이 자기 필드 값을 못 읽는 게 애매하다는 지적(예: +`Boldify`가 폰트별 굵기 보정을 하려면 현재 `Font` 필드를 읽어야 함). +같은 스레드에서 "Source가 항상 정해진 값만 담아야 하는 이유가 확정된 +건지, FuncSource(람다로 계산+self-emit하는 Source) 같은 건 왜 없는지"도 +같이 물어옴. + +**핵심 결론(전부 base 문서에 반영 완료)**: +- **`Modifier.Override(mod1, mod2, ...)`** — `component-composition-plan.md` + 3번 절에 2026-08-04부터 가칭 `Merge`로 이미 확정돼 있던 결합 유틸의 + 실제 동작을 확정하고 이름을 `Override`로 개명(중립적 "합침"이 아니라 + 명시적 "덮어쓰기"라 이름이 의미를 정직하게 반영해야 함). 뒤 인자가 + 필드 단위로 이김(기존 배열 flatten 규칙 재사용), 구현은 단순 필드별 + raw 교체 — setter가 이미 호출 시점에 함수/State를 즉시 처리해 저장하므로 + Modifier 필드는 항상 baked 값이라 특별한 분기 불필요. "baked 값 교체는 + 거기서 파생된 다른 필드에 소급 반영 안 됨"(Boldify가 FontWeight를 계산해 + 둔 뒤 Font가 Override로 바뀌어도 FontWeight는 예전 값 그대로)과 순서 + 의존성(`A:Override(B)` ≠ `B:Override(A)`) 둘 다 문서 경고 대상으로 확정. + **`Apply`로 전부 대체해 `Override`를 없애는 방안도 검토했으나 기각** — + 컴포넌트 경계(`props.Modifier`는 단일 named parameter라 배열 flatten이 + 안 닿음)라는 이미 확정된 실사용 니즈를 `Apply`만으로는 못 풀어서. + `base/modifier-plan.md` 9번 절. +- **`:Peek<>(key): T|State|nil`** — Modifier 필드를 확정(pull+recompute) + 하지 않고 raw 그대로 읽는 접근자. `Get`이 아니라 `Peek`인 이유는 이 + 프로젝트에서 `State:Get()`이 이미 "확정한다"는 의미로 굳어져 있어서 — + Modifier의 읽기는 정반대(State면 State 핸들 그대로) 동작이라 같은 + 동사를 못 씀. 반환 타입을 `T`로 자동 확정하지 않고 union 그대로 + 노출하는 이유는 4-1번 절 함수형 setter의 `old` 인자와 같은 원칙("현재 + 저장된 그대로 넘김") 재사용 — 자동 확정하면 타입에 안 드러나는 채로 + 반응성이 조용히 끊김. `.RealValue` 같은 별도 인덱싱 표면은 기각(이미 + `__index`가 setter 합성용으로 예약돼 있어 표면이 겹침). +- **`isState(x): boolean`** — `Peek`의 raw union을 분기하려면 필요. + Source가 State를 구조적으로 만족하므로 이거 하나로 Source도 같이 + 잡힘(`isSource` 불필요). duck-typing 대신 weak-key 레지스트리로 구현 + (rbvm 네임스페이스 추적과 같은 패턴 재사용) — `Peek`가 돌려주는 `T`가 + 임의의 테이블/userdata일 수 있어 duck-typing은 false positive나 일부 + Roblox userdata의 인덱싱 에러(pcall 필요)로 이어질 위험이 있음. 이 + 판별 로직 자체는 새 개념이 아니라 4-1번 setter가 이미 내부적으로 + 해야 했던 "필드가 State냐 plain이냐" 판별을 public 유틸로 승격한 것. + `base/bind-system-plan.md`의 `isState` 절. +- **FuncSource(값이 람다로 계산되고 self-emit하는 Source) 기각** — 사용자가 + 스스로 기각 논리를 제시했고("이미 Compute가 커버함"), 검증 결과 이미 + 확정된 두 원칙에서 그대로 연역됨: (1) Source는 "시작점"이라 다른 + 반응형 값에 자동 연결 안 됨(2026-08-04 6차 라운드, "Store가 Store를 + 담지 않는다" 확정 때 나온 원칙) — FuncSource는 다른 반응형 값에 종속된 + 계산이면서 겉으로는 origin인 척하는 것이라 이 원칙과 직접 충돌. + (2) `:With`가 clone 빌더가 아니라 진짜 노드여야 하는 이유(2026-08-07 + 세 번째 세션)가 "의존성이 구조적으로 안 보이면 디버그 그래프가 + 깨진다"였는데, FuncSource의 람다가 클로저로 캡쳐한 의존성은 정확히 + 그 문제를 재현함. 실제로 커버 안 되는 유스케이스도 없음 — "다른 + 반응형 값에서 계산"은 `Compute`, "clone 불가능한 값을 밖에서 바꾸고 + 알림"은 원천 Source+`Emit`으로 이미 전부 커버됨. 새 결정이 아니라 + 기존 확정 사항의 논리적 귀결이라 별도 base 절 신설 없이 여기 세션 + 요약으로만 기록(quadnomicon 소재로 재사용 가능하도록). + +**같은 세션 바로 후속 — 문서화 톤 보강(사용자 강조)**: `Override`는 범용 +조합 도구가 아니라 `Frame{mod1, mod2}`의 컴포넌트 경계판(단일 named +parameter 슬롯에 독립적으로 만들어진 값 두 개 이상을 넣어야 하는 특수 +상황)으로 좁게 문서화할 것 — "특정 modifier를 계속 바꿔나간다"는 요구는 +항상 `Apply` + 커링/일급 함수 전달을 기본 관용구로 유도. `Apply` 자체도 +`factory(self)` 호출 sugar 그 이상이 아니라는 걸 명시 — `factory`가 +`Peek`한 값이 기대와 다르면 `error`를 던지든 뭘 하든 전부 `factory` +저작자 책임, `Apply`가 검증/보장을 대신 해준다고 오해하면 안 됨. 둘 다 +`base/modifier-plan.md` 8/9번 절에 반영 완료. + +**같은 세션 두 번째 후속 — `Apply` vs `Override` 성능 기준 확정.** +"무거운 Modifier를 대량 생성할 때 `Apply`의 clone 비용이 누적되지 +않냐"는 우려에서 두 방안 검토 후 결론: **`Apply`를 mutable로 바꾸는 +방안은 기각**(3번 절 immutable 확정 이유 — 형제 서브트리 오염 방지 — +가 clone 비용 절감보다 우선순위 높음, 재확인). 대신 **판단 기준을 +"이질적/동질적 프로퍼티"가 아니라 "필드 간 계산 의존성 유무"로 +명확화** — 한쪽이 `Peek`으로 다른 쪽의 baked 값을 읽어 반영해야 하면 +이질적으로 보여도 `Apply`, 서로 완전히 독립이면 동질적으로 보여도 +`Override` 가능. 계산 의존성 없는 재사용 조각(배경/텍스트/레이아웃처럼 +서로 다른 서브시스템이 한 번만 만드는 값)은 모듈 상수로 만들어두고 +인스턴스마다 `Override`로 결합하는 게 실제 최적화 패턴 — 단 이건 +"`Override`가 내부적으로 캐싱해준다"가 아니라 사용자가 값을 재사용하는 +평범한 패턴일 뿐, 라이브러리에 새 캐싱 레이어가 생기는 게 아님을 +문서에 명시하기로 함. `base/modifier-plan.md` 9-1번 절. + +**같은 세션 세 번째 후속 — "`Apply` 경계에서만 clone, 안쪽은 mutable" +절충안도 검토 후 기각.** clone 횟수를 체인 길이가 아니라 `Apply` 호출당 +1번으로 줄이는 절충을 사용자가 직접 제시했으나, `Apply`를 거치지 않고 +setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 공유 +레퍼런스가 그대로 오염될 수 있음(서브트리에서 폰트 두께만 바꿔도 터짐) +— "어디서 터지느냐만 달라지는" 비일관적 절충이라 실익 없다고 판단해 +기각. 전부 clone하는 현재 방식 유지 확정. `base/modifier-plan.md` +9-1번 (a-1) 절. + +**다음 세션이 할 일**: 안 바뀜(위 2026-08-06 네 번째 세션 절 참고, +`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 +자체는 그대로. + **미해결로 남긴 것 — 임의로 결론내지 않음**: Effect가 `state:Effect()` 형태로 Observer를 확장하는 변형인지, 완전히 독립된 free function인지가 불명확함(사용자가 "확인 필요, 아니라면 논의해야 할 상태로 남겨두라"고 diff --git a/ROADMAP.md b/ROADMAP.md index 751eaf4..dfedd72 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -92,10 +92,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M7 — Modifier - [ ] flatten-before-dispatch, immutable `table.clone` 체이닝 -- [ ] `Modifier.Merge`(가칭) +- [ ] `Modifier.Override(mod1, mod2, ...)`(가칭, 구 `Merge`) — 필드별 raw + 덮어쓰기, 특별한 State/함수 분기 불필요(`modifier-plan.md` 9번) - [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) +- [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 + + `isState(x): boolean`(weak-key 레지스트리 기반, quad-base 공용 + 유틸 — `modifier-plan.md` 9번, `bind-system-plan.md`의 `isState` 절) - [ ] 인라인 키로 modifier 필드를 명시적으로 지우는 문제 확인 — `None` (가칭) 센티널 프리미티브 도입 여부(`modifier-plan.md` 2-1번, 아직 미정 — 착수 전 사용자 확인 필요, 확정 안 되면 이번 마일스톤은 From 4dd7659620bfa5b0603a97d44faa0eb88e5c9637 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 16:01:05 +0900 Subject: [PATCH 35/62] =?UTF-8?q?docs(base):=20Override=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=ED=83=80=EC=9E=85=20Modifier=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=8B=9C=EA=B7=B8=EB=8B=88=EC=B2=98=20=EB=AF=B8=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FrameModifier/GuiObjectModifier처럼 서브타입 관계인 Modifier끼리 Override로 섞을 때 필드 setter 리턴 타입이 갈려 구조적 서브타이핑이 안 풀릴 수 있음을 modifier-plan.md 9-2번에 남기고, 실 Luau 테스트가 필요한 항목으로 ROADMAP.md M7에 추가. Co-Authored-By: Claude Sonnet 5 --- .claude/base/modifier-plan.md | 30 ++++++++++++++++++++++++++++++ ROADMAP.md | 4 ++++ 2 files changed, 34 insertions(+) diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 0e5894e..ae9eb76 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -389,6 +389,36 @@ Modifier 값을 변수/모듈 상수로 만들어 재사용하는, 기존에도 근거 없는 선제 최적화라 설계하지 않음 — CLAUDE.md의 "드문 오용/가상 미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 동일. +### 9-2. `Override`가 서로 다른(그러나 상하위 관계인) Modifier 타입을 섞는 경우 — +타입 시그니처 미확정, 실 Luau 테스트 필요 (2026-08-07 다섯 번째 세션 후속) + +**문제**: Modifier 타입은 위 4번 절 "FrameModifier 타입" 언급대로 Roblox +클래스별로 생성기가 뽑아내는 flat 타입인데, 그 밑의 Roblox 클래스 자체엔 +서브타입 관계가 있음(`Frame`이 `GuiObject`의 서브클래스) — 그럼 생성된 +`FrameModifier`도 `GuiObjectModifier`의 서브타입이어야 자연스럽고, 실제로 +`Modifier.Override(guiObjectMod, frameMod)`처럼 공통 상위 클래스 스타일 +프리셋과 하위 클래스 전용 보정을 섞어 합치는 패턴이 필요해 보임(사용자 +지적, 2026-08-07). + +**막히는 지점**: 필드 setter 메소드(`:FontSize` 류)는 각 타입마다 반환 +타입이 자기 자신(`self`, 즉 `FrameModifier`는 `FrameModifier`를, +`GuiObjectModifier`는 `GuiObjectModifier`를 리턴)이라, 같은 이름의 메소드 +필드끼리 리턴 타입이 갈려서 단순 구조적 서브타이핑만으로는 안 풀릴 가능성이 +있음. + +**후보안(미검증)**: 같은 이름의 메소드 필드는 리턴 타입이 다르니 그냥 +`any`로 뭉개고, 나머지(메소드가 아닌 순수 데이터 필드) 쪽만 `[string]: nil`류 +인덱스 시그니처 조건이 성립하면 통과시키는 식으로 서브타입 호환을 흉내낼 수 +있는지 — 이게 실제로 Luau 솔버에서 받아들여지는 타입 구성인지는 추론만으로 +결론 낼 수 없고 실제 코드로 테스트해봐야 함(M0가 이미 검증 대상으로 삼은 +"추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"과 같은 성격의 모호함). + +**당장의 fallback**: 위 후보안이 Luau에서 실제로 안 먹히는 걸로 확인되면, +`Modifier.Override`의 타입 시그니처를 일단 `Override(...: any): any`류로 +느슨하게 열어 정적 체크를 포기 — 이건 임시 처치로 명시하고, M7 실제 구현 +시점에 실 테스트 결과에 따라 다시 좁히는 걸 목표로 로드맵에 남김 +(`ROADMAP.md` M7). + **`:Peek<>(key): T | State | nil`** — Modifier 필드를 확정하지 않고 그대로 읽는 접근자. 이름을 `Get`이 아니라 `Peek`로 정한 이유: 이 프로젝트 전역에서 `State:Get()`은 "확정한다"(pull + recompute + 최종값 diff --git a/ROADMAP.md b/ROADMAP.md index dfedd72..b267f66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -94,6 +94,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] flatten-before-dispatch, immutable `table.clone` 체이닝 - [ ] `Modifier.Override(mod1, mod2, ...)`(가칭, 구 `Merge`) — 필드별 raw 덮어쓰기, 특별한 State/함수 분기 불필요(`modifier-plan.md` 9번) +- [ ] `Override`가 서브타입 관계인 서로 다른 Modifier 타입(예: `FrameModifier`/ + `GuiObjectModifier`)을 섞을 때의 타입 시그니처 실 Luau 테스트 + (`modifier-plan.md` 9-2번, 미검증 — 안 되면 일단 `Override(...: any): + any`로 느슨하게 열어두고 이 항목으로 되돌아올 것) - [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) From 8ce3c112138689e8a30ef28a46beb19b3adf5620 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 16:01:24 +0900 Subject: [PATCH 36/62] =?UTF-8?q?decide(base):=20Ref/PreRef=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=20API=20=ED=99=95=EC=A0=95,=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B6=84=EB=A6=AC,=20Tween=20GC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ref API를 .Value(읽기 전용) + :Set(value)/:Callback(fn)/:Wait(thread?) 세 메소드로 확정 — 전부 mutation 패턴이라 자기 자신을 반환해 `if ref.Value then ref.Value else ref:Wait().Value` 관용구가 성립. :Set()이 대기자를 깨울 때 넘기는 resume 인자를 value에서 self로 정정 (안 그러면 :Wait() 뒤 .Value 체이닝이 안 풀림). - :Wait(thread?)의 thread 인자: 생략하면 coroutine.running()을 캡처해 yield, 명시하면 등록만 하고 yield 없이 즉시 self 반환. - Ref/PreRef를 1프리미티브-1파일 컨벤션에 맞춰 Ref.luau/PreRef.luau로 분리(런타임은 공유), architecture.md 소스트리 갱신. - Tween의 per-instance 저장소(inst로 weak-keyed된 릴레이션 안에 key별 릴레이션이 중첩된 구조)가 이미 설계대로 GC-안전함을 확인, 이유를 bind-system-plan.md/tween-plan.md에 명시. Co-Authored-By: Claude Sonnet 5 --- .claude/base/architecture.md | 3 +- .claude/base/bind-system-plan.md | 90 ++++++++++++++++++++++++-------- .claude/research/tween-plan.md | 6 ++- CLAUDE.md | 40 ++++++++++++++ 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 5d6ba00..ae86998 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -132,7 +132,8 @@ quad/ │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) │ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" -│ ├── Ref.luau # 범용 값 박스(.Value/:Wait()/콜백) + 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 + PreRef(children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화, `bind-system-plan.md` "PreRef 신설" 절) +│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋)와 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 +│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `bind-system-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ └── init.luau └── quad-roblox/ ├── wally.toml diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 0a678b7..3629f1a 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -95,7 +95,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 멈추거나 끝냄). **base가 이걸 범용 유틸로 제공**(`base.perInstanceState(inst)` 류, 정확한 이름/모양은 구현 단계에서 확정) — 모든 핸들러가 재사용, 각자 WeakMap을 새로 만들지 않음. `base/lifecycle-pattern.md`의 "생명 바인드 유틸"과 - 짝을 이루는 유틸. + 짝을 이루는 유틸. **왜 GC-안전한가(2026-08-07 여섯 번째 세션, 명시화)**: + 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 + 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 사라지는 + 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — 별도 + cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 + 자동으로 같이 죽는 것까지 포함, `research/tween-plan.md` 참고). - **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. @@ -215,24 +220,58 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 `ref.Value?.()`처럼 호출하는 함수를 Ref에 담아 넘기는 패턴 — React의 imperative handle과 동일한 이유(비싼 연산이라 온디맨드가 맞음, 값이 최신인지 아닌지도 애매해짐). -- **API 모양**: `.Value`(get/set) + `:Wait()`(coroutine 컨텍스트에서 - 사용 — 렌더 함수 바디 안에서 `return` 위에 바로 못 씀, 그래서 콜백도 - 같이 필요) + 콜백 등록(복수 허용, 이미 채워져 있으면 등록 즉시 그 - 값으로 1회 호출 — nil/미설정 상태여도 그 상태 그대로 호출. React의 - `useEffect`가 매번 `.current` 존재 여부부터 체크하는 것과 같은 이유, - Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 - "이미 채워졌는지" 확인이 항상 필요함). `:Wait()`의 대기자 리스트와 - 콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 nil 처리, - Luau의 일반화 for는 성긴 배열도 잘 순회함). - - **구현 디테일(2026-08-07 세 번째 세션, 사용자 제안)**: 값이 새로 - `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 - 한 번만 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 - 보고 `coroutine.resume(v, value)` 후 `[i] = nil`(1회성 소진), 아니면 - 일반 콜백 함수로 보고 그냥 `v(value)`(소진 안 함, 계속 유지)로 - 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 - 하나로 분기 가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈 - 인덱스가 있어도 없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도 - 일반화 `for`가 계속 잘 순회하므로 압축(compact)할 필요도 없음). +- **API 모양**: `.Value`(읽기 전용 필드) + `:Set(value) -> Ref`(쓰기) + + `:Callback(fn) -> Ref`(콜백 등록, 복수 허용) + `:Wait(thread?) -> Ref` + (coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 `return` 위에 바로 + 못 씀, 그래서 콜백도 같이 필요) **세 메소드로 확정(2026-08-07 여섯 번째 + 세션)**. `:Set`/`:Callback`/`:Wait` 전부 **mutation 패턴이라 자기 자신 + (`Ref`)을 반환** — `store.key:Set(value)`류 "값을 바꾸는 연산엔 `:` + 체이닝 허용" 원칙(`base/store-semantics.md` 190행)의 자연스러운 재적용. + 이 self-반환 덕에 Luau의 `if`-표현식과 결합해 흔한 관용구를 한 줄로 + 쓸 수 있음(사용자 제시 예): + ```luau + local t = if ref.Value + then ref.Value + else ref:Wait().Value + ``` + - 콜백은 이미 채워져 있으면 등록 즉시 그 값으로 1회 호출됨 — nil/미설정 + 상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current` + 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 + 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 + 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 + 가능(발화 후 해당 인덱스만 nil 처리, Luau의 일반화 for는 성긴 배열도 + 잘 순회함). + - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 + 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 + 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 + **자기 자신을 정지**시킴(값이 채워지면 재개). 명시적으로 다른 thread를 + 넘기면 **그 thread를 대기자로 등록만 하고 정지 없이 즉시 `self`를 + 반환** — 코루틴 역학상 `coroutine.yield()`는 지금 실행 중인 코루틴만 + 정지시킬 수 있고, 어딘가 이미 정지해 있는 남의 thread를 여기서 대신 + 정지시킬 수는 없기 때문(그 thread는 이미 정지 상태). 이 표면의 + 용도: 사용자가 직접 관리하는 스케줄러가 이미 만들어 둔(어딘가 다른 + 지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은 + 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`가 + `nil`이면 yield, 있으면 yield 안 함. + - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume + payload 정정)**: 값이 새로 `:Set()`될 때, 같은 배열 하나를 + `for i, v in <배열> do ... end`로 한 번만 순회하면서 `type(v) == + "thread"`면 `:Wait()`가 만든 대기자로 보고 **`coroutine.resume(v, self)`** + (즉 값이 아니라 **Ref 자기 자신**을 resume 인자로 넘김 — 위 self-반환 + 관용구가 `:Wait()`의 yield 경로에서도 그대로 성립하게 하기 위해, + `coroutine.yield()`의 리턴값이 곧 `self`가 되도록 정정. 세 번째 + 세션 원안은 `value`를 넘기는 것으로 적혀 있었으나 이러면 + `ref:Wait().Value`가 안 풀려서 이번 세션에 정정) 후 `[i] = nil` + (1회성 소진), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 + 여전히 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — + 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 + 가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈 인덱스가 있어도 + 없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도 일반화 `for`가 + 계속 잘 순회하므로 압축(compact)할 필요도 없음). + - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 + `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 + 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 + 둠. - **제네릭 시그니처(2026-08-07 확정): `Ref(T) -> Ref` — 단일 타입 파라미터.** React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 분리"하는 2파라미터 설계도 검토했으나(예: `Ref<>(null)` @@ -298,8 +337,17 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 채워진 self-ref를 읽으면 터짐. **해결**: 이 케이스만 별도 타입 `PreRef`로 분리. -- **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Wait()`/콜백 API) — - 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. +- **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Set()`/`:Callback()`/ + `:Wait()` API) — 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. + **소스 파일은 분리(2026-08-07 여섯 번째 세션)**: `Ref`는 이제 그 자체로 + 충분히 완결된 프리미티브고 `PreRef`도 "children 배열 전용, 위치 무관 + 호이스팅"이라는 특이한 제약을 가진 별개 프리미티브라, 기존 프리미티브당 + 1파일 컨벤션(`modifier-plan.md`/`slot-plan.md`류, Blocker/Effect를 + 같은 이유로 분리한 2026-08-07 네 번째 세션과 같은 판단)을 따라 + `Ref.luau`/`PreRef.luau` 두 파일로 쪼갬 — 런타임 로직은 여전히 공유 + (`PreRef.luau`가 `Ref.luau`를 그대로 불러다 브랜드 태그만 얹음), 파일 + 분리는 순수 조직 문제라 위 재사용 결정과 상충 없음. `base/architecture.md` + 소스트리에 반영 완료. - **오직 children 배열의 리터럴 아이템으로만 놓을 수 있다** — **Modifier 필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.** - Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 872fd6c..1b949a0 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -71,7 +71,11 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에 생성한 실제 Tween 객체"는 `base/bind-system-plan.md`가 말하는 base 제공 범용 유틸(`inst`를 키로 하는 weak-keyed per-instance 상태 저장소)에 담아두면 -됨. +됨. **GC 확인(2026-08-07 여섯 번째 세션, 사용자 제안 검증)**: 이 저장소는 +`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된 +구조라, `inst`가 죽으면 그 안에 담긴 Tween 인스턴스 릴레이션도 별도 +정리 로직 없이 같이 GC됨 — `base/bind-system-plan.md`의 "핸들러 내부 +상태 저장" 절 "왜 GC-안전한가" 참고. ## `retract`는 완전 소멸(Destroy) 시엔 호출되지 않는다 diff --git a/CLAUDE.md b/CLAUDE.md index ada599e..6f208b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -850,3 +850,43 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 결정 자체는 늘지 않았음 — 단, M3 체크리스트에 `Blocker.luau` 항목이 하나 추가된 것과, 위 Effect/Observer 미해결 항목은 M3~M4 착수 전에 확인해야 함. + +## 2026-08-07 여섯 번째 세션 — Ref/PreRef 메소드 API 확정, 파일 분리, Tween GC 저장 구조 확인 + +사용자가 메모 형태로 두 가지를 던짐: (1) Tween 인스턴스를 per-instance +저장소에 담는 구조가 실제로 GC-안전한지, (2) Ref가 이제 충분히 완결된 +프리미티브이니 PreRef와 파일을 분리하고, `:Set`/`:Callback`/`:Wait` +세 메소드로 API를 굳히자는 제안(전부 mutation 패턴이라 자기 자신을 +반환). 둘 다 검증 후 반영 완료: + +- **Tween per-instance 저장소는 이미 확정된 구조 그대로 GC-안전함** — + `inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된 + 모양이라(`base.perInstanceState(inst)`), `inst`가 죽으면 중첩된 Tween + 인스턴스 릴레이션도 별도 정리 없이 같이 GC됨 — 새 결정 아니라 기존 + 설계(`bind-system-plan.md` "핸들러 내부 상태 저장" 절)의 확인, "왜 + GC-안전한가" 설명만 명시적으로 추가. +- **Ref API가 `.Value`(읽기 전용) + `:Set(value)`/`:Callback(fn)`/ + `:Wait(thread?)`(전부 self 반환)로 확정.** self-반환 덕에 + `if ref.Value then ref.Value else ref:Wait().Value` 관용구가 성립 — + 이걸 성립시키려고 `:Set()`이 `coroutine.resume`할 때 넘기는 인자를 + 기존 문서(세 번째 세션 원안)의 `value`에서 **`self`**로 정정함(안 + 그러면 `:Wait()`의 yield 리턴값에 `.Value`를 체이닝할 방법이 없었음). + `:Wait(thread?)`의 `thread` 인자는 생략 시 `coroutine.running()`을 + 캡처해 진짜로 yield하고, 명시적으로 넘기면 그 thread를 등록만 하고 + yield 없이 즉시 `self` 반환(코루틴 역학상 남의 thread를 여기서 대신 + 정지시킬 수 없어서) — 사용자가 직접 관리하는 스케줄러가 이미 어딘가서 + 정지시켜 둔 thread를 등록만 해두고 호출부는 안 블록되고 싶은 유스케이스. + 콜백은 여전히 raw 값을 받음(Ref 자신이 아니라). +- **파일 분리**: `Ref`는 그 자체로 완결된 프리미티브, `PreRef`도 "children + 배열 전용, 위치 무관 호이스팅"이라는 특이한 제약을 가진 별개 + 프리미티브라 기존 1프리미티브-1파일 컨벤션(Blocker/Effect 분리와 + 같은 이유)을 따라 `Ref.luau`/`PreRef.luau`로 쪼갬 — 런타임은 여전히 + 공유(`PreRef`가 `Ref`를 재사용, 브랜드 태그만 다름), `base/architecture.md` + 소스트리에 반영 완료. +- 전부 `base/bind-system-plan.md`(Ref/PreRef 절)와 `research/tween-plan.md`에 + 반영 완료. `.claude/question.md`엔 이미 반영돼 있던 "Ref 이름 자체는 + 용어 정리 대상" 항목과 모순 없음(이번 세션은 메소드 이름만 확정, Ref라는 + 타입 이름 자체는 여전히 가칭). + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 이미 +설계된 것의 세부 마무리라 M0 착수 우선순위 자체는 그대로. From 8fc6dd3b8c73ade4c32deab1a10bf113a555fff1 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 16:20:54 +0900 Subject: [PATCH 37/62] =?UTF-8?q?decide(base):=20Observer=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=EC=8B=A4=ED=96=89=20=ED=99=95=EC=A0=95,=20Effect?= =?UTF-8?q?=EA=B0=80=20Observer=EB=A5=BC=20=EC=A1=B0=ED=95=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state:Observer(fn)는 등록 즉시 1회 실행되는 것으로 확정 — 초기화 순서 디버깅 문제를 피하고, store-bind 프로퍼티 핸들러가 "초기값 적용"과 "이후 변경 반영"을 같은 코드 경로로 통일할 수 있게 됨. - Effect(fn, state?)로 확정 — state 생략 시 기존 스펙(설치 1회 + 확정 정리) 유지, state 지정 시 내부적으로 state:Observer(...)를 조합해 재실행 + 자동 cleanup 배선(React useEffect와 동형). 다수 의존성은 :With(...)로 묶어서 넘김. 여전히 자유 함수(메소드 아님) — leaf 생명주기 바인딩을 state가 소유하지 않아서. - fn 커링 스타일을 Effect/Observer 공통 모듈화 관용구로 권장, state:Apply 커링 조합기 아이디어는 백로그로만 기록. - question.md 0번의 Effect/Observer 열린 질문 해소. Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 24 +++- .claude/base/effect-plan.md | 117 +++++++++++------- .claude/question.md | 17 +-- .../research/additional-primitives-plan.md | 2 +- CLAUDE.md | 37 +++++- 5 files changed, 136 insertions(+), 61 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 3629f1a..7706e07 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -584,6 +584,17 @@ Frame { 이러면 `observer`는 `Frame`이 살아있는 동안만 유지되고, `Frame`이 retract/Destroy되면 자동으로 정리됨. +- **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, + 사용자 확정 — 이전까지 미명시였던 항목).** 근거: (1) 이미 채워진 + State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 + 일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회 + 실행을 하지 말아야 할 구체적 근거가 약함. (3) **이 결정 덕에 + Observer 하나로 "초기값 적용"과 "이후 변경 반영"을 같은 코드 경로로 + 통일할 수 있음** — 예: State→프로퍼티 store-bind 핸들러가 그냥 + `state:Observer(function() inst.SomeProp = state:Get() end)`를 걸어 + 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 + 따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도 + 이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨. - **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(아래 "Store/State/Source 온톨로지" 절)이 그대로 적용됨: `fn`은 "뭔가 @@ -593,6 +604,10 @@ retract/Destroy되면 자동으로 정리됨. `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` 호출 여부를 작성자가 직접 결정하게 열어둔 것. +- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 + 번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제 + `fn`을 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링(`modifier-plan.md` + 8번)과 같은 결. `base/effect-plan.md`의 Effect도 동일하게 권장. - **base가 제공하는 것은 `isObserver`류 타입 판별자 하나** — children 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 `CreatedRef`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 @@ -615,7 +630,14 @@ retract/Destroy되면 자동으로 정리됨. 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 하면 별문제 없음(사용자 판단). -### `:Subscribe()`/`:Unsubscribe()` — 리프에 안 붙는 "전역/독립" Observer용 (2026-08-06 후속 세션) +### 백로그(미확정) — `state:Apply(...)`: `:With`+`:Compute` 등록을 커링으로 자동화 (2026-08-07 여섯 번째 세션, 사용자 제안) + +Effect/Observer의 `fn` 커링 관용구 논의 중 나온 인접 아이디어 — `Modifier`의 +`:Apply(factory)`(팩토리 체이닝, `modifier-plan.md` 8번)와 비슷하게, 여러 +개를 커링으로 받아 알아서 `:With`/`:Compute` 등록을 대신 해주는 +`state:Apply(...)` 같은 조합기가 있으면 편리할 수 있다는 제안. **지금 결정 +필요 없음 — 백로그로만 기록.** 구체 시그니처/필요성 검증 없음, 나중에 +`:With`/`:Compute` 관용구가 실제로 자주 반복되는 게 확인되면 다시 논의. **문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 "어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index e8e7627..3bf439f 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -1,60 +1,83 @@ -# Effect — leaf 죽음에 확정 정리, 재실행 개념 없음 +# Effect — 설치 + 확정 정리, `state` 있으면 Observer를 감싸 재실행도 지원 **상태**: base — `research/additional-primitives-plan.md`(다른 프레임워크 대비 갭 분석)에서 갈라져 나온 확정 프리미티브. `base/blocker-plan.md`(같은 -조사에서 나온 다른 확정 프리미티브)와는 서로 무관 — Effect는 Store/State -작업이나 Ref/PreRef와도 파생 관계가 아닌 완전히 독립된 요소라 별도 파일로 -둔다(2026-08-07 문서 정리에서 한 파일로 합쳤던 걸 다시 분리). +조사에서 나온 다른 확정 프리미티브)와는 서로 무관 — Store/State 작업이나 +Ref/PreRef와 파생 관계는 아니라 별도 파일로 둔다(2026-08-07 문서 정리에서 +한 파일로 합쳤던 걸 다시 분리). 단 `state` 인자를 받는 형태는 내부적으로 +Observer를 조합해서 만들어짐(아래 참고, 2026-08-07 여섯 번째 세션 확정) — +"Observer와 무관한 완전 독립 프리미티브"였던 이전 서술은 정정됨. -**Observer와는 별개의, 완전히 새로운 요소로 확정** — Ref/PreRef 같은 -서로 파생된 관계가 아니라 독립적으로 존재하는 primitive. Roblox엔 -`task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이 흔하고, Luau -테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는 순간"을 아는 -유일한 방법은 `Instance.Destroying`류 명시적 신호뿐 — 이런 케이스(타이머 -시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive로 합의됨. +**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) -> EffectHandle -- fn을 즉시 1회 실행, 리턴값(nil | () -> ())은 - -- 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출 +Effect(fn, state?) -> EffectHandle ``` -**재실행 개념이 없다** — 값 변화에 반응해 다시 도는 건 Observer(+클로저로 -직접 짠 cleanup)의 영역이고, Effect는 순수하게 "설치 + 확정 정리" 페어 -하나만 담당한다. children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 -그대로 재사용(그 leaf가 살아있는 동안만 유효, leaf가 죽으면 정리 콜백 -호출). 비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 -Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분. +**`state` 생략 시**: `fn()`을 즉시 1회 실행, 리턴값(`nil | () -> ()`)은 +이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음 +(mount/unmount 전용, React `useEffect(fn, [])`와 동형). -**Observer에 cleanup 반환 계약을 추가하는 안은 기각됨** — React `useEffect`류로 -`fn`이 `nil | () -> ()`를 반환하면 다음 재실행 직전에 그걸 불러주는 안을 -검토했으나, 클로저 업밸류로 이미 쉽게 되고 잘 작동해서(`local lastConn; -state:Observer(function() if lastConn then lastConn:Disconnect() end; -lastConn = ... end)`) 프레임워크가 이걸 대신해줄 이유가 약하다는 판단 — -`state:Observer(fn)` 자체는 여전히 재실행 계약만 갖고, Effect가 별도로 -"1회 설치 + 확정 정리"를 담당하는 이 분리 구조가 유지됨. +**`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 전부 같은 +반환 계약 하나로 처리). -## ⚠️ 미해결 — Effect와 Observer의 관계, 사용자 확인 필요 +- **다수 의존성은 `: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`를 굳이 넘겨서 + 재실행을 유발할 필요 없음. -**임의로 결론내지 않고 열어둠(2026-08-07 문서 정리 세션)**: 위 스펙은 -`Effect(fn)`를 State에 종속되지 않는 완전한 자유 함수로 서술하지만, 아래 -두 가지가 문서상 명확히 확인되지 않음: +children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 그대로 재사용(그 +leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백 호출). 비용은 +leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 +비쌈) — 필요할 때만 쓰는 걸로 충분. -1. **`Effect`가 실제로는 `state:Effect(fn)`처럼 State의 메소드(=Observer의 - 변형, "재실행 없음 + 확정 정리 추가"만 다른 버전)로 구현/노출되어야 - 하는 것 아닌가?** — 그렇다면 "독립 존재 가능한 프리미티브 vs 원천에 - 종속된 파생 데이터"(`base/store-semantics.md`) 분류상 Effect도 - Observer처럼 후자(자유 함수 생성자 없음, 항상 `:` 메소드)로 재분류해야 - 함. 지금 이 문서는 이전 조사(`research/additional-primitives-plan.md`)를 - 따라 자유 함수로 서술했지만, 이게 최종 확정인지는 불명확. -2. **`state:Observer(fn)`가 생성 시점에 `fn`을 즉시 1회 실행하는지가 문서 - 어디에도 명시돼 있지 않음** — `base/bind-system-plan.md`의 Observer - 절은 "값을 안 실어줌, `fn` 본문에서 `Get()`을 다시 읽어야 함"만 - 명시할 뿐 "생성 즉시 1회 호출되는지"는 다루지 않는다. Effect는 - "즉시 1회 실행"이 스펙에 명시돼 있어 이 부분만 보면 둘이 겹쳐 - 보인다. +**Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React +`useEffect`류로 `fn`이 `nil | () -> ()`를 반환하면 다음 재실행 직전에 그걸 +불러주는 안을 검토했으나, 클로저 업밸류로 이미 쉽게 되고 잘 작동해서(`local +lastConn; state:Observer(function() if lastConn then lastConn:Disconnect() +end; lastConn = ... end)`) **Observer 자체**가 이걸 대신해줄 이유는 여전히 +약함. **이 기각과 위 Effect 설계는 상충하지 않는다** — 그때 기각한 건 +"Observer 자체에 이 복잡도를 넣지 말자"였지 "이 패턴 자체가 무용하다"가 +아니었음. 자동 cleanup 배선이 필요한 사람만 opt-in으로 쓰는 별도 계층 +(Effect)으로 분리해 얹었을 뿐, Observer의 기본 계약(재실행 신호만, cleanup은 +클로저로 직접)은 그대로 가볍게 유지됨. -이 두 질문이 풀리면 Effect가 (a) 완전히 별개인 자유 함수 primitive로 -남는지, (b) `state:Effect(fn)`로 Observer 계열에 합류하는지가 갈린다. -**확인 전까지는 위에 적은 자유 함수 스펙을 잠정 스펙으로 두되, 구현 -착수(M3~M4 전후) 전에 반드시 사용자와 다시 확인할 것** — `.claude/ -question.md`에 같은 항목 등재됨. +## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) + +**과거 미해결이었던 두 질문 모두 확정**: +1. Effect는 자유 함수로 확정(`state:Effect(fn)` 메소드 아님) — 위 "Effect와 + Observer의 관계 확정" 절 참고. `state` 인자가 있어도 실제 leaf 생명주기 + 바인딩을 `state`가 소유하지 않아서 메소드로 만들 필연성이 없었음. +2. `state:Observer(fn)`는 등록 즉시 1회 실행되는 것으로 확정(`base/ + bind-system-plan.md`의 Observer 절 참고) — 이 덕에 Effect가 `state`를 + 받을 때 Observer를 그대로 조합해 재사용할 수 있게 됨(별도 "설치 시 + 1회 실행" 로직을 Effect가 따로 만들 필요 없음). + +`.claude/question.md` 0번의 관련 항목도 해소됨으로 갱신 완료. diff --git a/.claude/question.md b/.claude/question.md index da44a8e..fb56a81 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -30,16 +30,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 필요 — 최종 이름만 미정(아래 "용어 정리" 절에 후보 추가). **사용자가 "작업 전에 모든 정의를 마치고 싶다"고 명시** — M0 이전 완전 확정 목표. 상세는 `research/additional-primitives-plan.md`(이제 이 주제 전용). -- **Effect가 Observer의 변형(`state:Effect()`)인지, 완전히 독립된 free - function인지 — 신규, 2026-08-07 문서 정리 세션에서 발견.** `base/ - effect-plan.md`가 지금까지의 조사대로 Effect를 "재실행 없는 - 독립 free function"으로 서술해뒀지만, 사용자가 직접 `state:Effect()` - 형태(=Observer에 "확정 정리" 계약만 추가된 변형)로 기억하고 있어서 - 확인이 필요함. 관련 하위 질문: `state:Observer(fn)`가 생성 시점에 - `fn`을 즉시 1회 실행하는지도 현재 문서 어디에도 명시돼 있지 않음(Effect는 - "즉시 1회 실행"이 스펙에 있음 — 이 부분만 보면 둘이 겹쳐 보이는 이유). - **임의로 결론내지 않고 열어둠** — 구현 착수(M3~M4 전후) 전에 확인 필요, - 상세는 `base/effect-plan.md`의 "미해결" 절. +- **[해소됨, 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` "빈 자리 아닌 것" 절). @@ -206,7 +201,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** | 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Override`) | `base/component-composition-plan.md` | | 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | | Blocker(값 기반 emit 지연/합치기) | `base/blocker-plan.md` | -| Effect(leaf 죽음에 확정 정리 — 단 Observer와의 관계는 위 0번 열린 질문 참고) | `base/effect-plan.md` | +| Effect(설치+확정 정리, `state` 있으면 Observer 조합해 재실행도 지원 — 확정) | `base/effect-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` | diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index e0a9727..554712e 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -30,7 +30,7 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 | 후보 | 판정 | 현재 위치 | |---|---|---| | 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** — 아직 열려있음 | 이 문서(아래) | -| Effect(leaf 죽음에 확정 정리) | 채택, 단 Observer와의 관계는 미해결 | `base/effect-plan.md` | +| 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` | diff --git a/CLAUDE.md b/CLAUDE.md index 6f208b7..dd68667 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -851,7 +851,7 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 하나 추가된 것과, 위 Effect/Observer 미해결 항목은 M3~M4 착수 전에 확인해야 함. -## 2026-08-07 여섯 번째 세션 — Ref/PreRef 메소드 API 확정, 파일 분리, Tween GC 저장 구조 확인 +## 2026-08-07 여섯 번째 세션 — Ref/PreRef 메소드 API 확정, 파일 분리, Tween GC 저장 구조 확인, Effect/Observer 관계 해소 사용자가 메모 형태로 두 가지를 던짐: (1) Tween 인스턴스를 per-instance 저장소에 담는 구조가 실제로 GC-안전한지, (2) Ref가 이제 충분히 완결된 @@ -888,5 +888,40 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 용어 정리 대상" 항목과 모순 없음(이번 세션은 메소드 이름만 확정, Ref라는 타입 이름 자체는 여전히 가칭). +**같은 세션 후반 — `.claude/question.md` 0번의 마지막 미해결 항목(Effect가 +`state:Effect()`인지 자유 함수인지) 해소.** 사용자가 직접 "정해볼까" 하고 +제기해 라이브로 논의, 다음으로 확정(전부 `base/effect-plan.md`/ +`base/bind-system-plan.md`에 반영): + +- **`state:Observer(fn)`는 등록 즉시 1회 실행되는 것으로 확정** — 근거: + (1) 이미 채워진 State를 나중에 구독하면 반영 연산이 아예 한 번도 안 + 일어나는 초기화-순서 디버깅 문제, (2) 초회 실행을 안 해야 할 구체적 + 근거가 약함, (3) 이러면 Observer 하나로 "초기값 적용"과 "이후 변경 + 반영"이 같은 코드 경로로 통일됨(store-bind 프로퍼티 핸들러가 최초 + 적용용 코드를 별도로 안 짜도 됨). +- **`Effect(fn, state?) -> EffectHandle`로 확정** — `state` 생략 시 기존 + 스펙 그대로(설치 1회 + leaf 죽을 때 확정 정리, 재실행 없음). `state` + 지정 시 **내부적으로 `state:Observer(...)`를 조합** — Observer가 이제 + 즉시 1회 실행되므로 그 첫 실행이 설치를 겸하고, 이후 무효화마다 + 직전 cleanup 호출 후 `fn` 재호출, leaf 사망 시 마지막 cleanup 1회 — + React `useEffect(fn, [dep])`와 동형. 다수 의존성은 `:With(...)`로 먼저 + 하나의 State로 묶어서 넘기는 쪽으로 확정(React식 별도 deps 배열 + 안 만듦 — 같은 일 하는 두 번째 경로 방지 원칙). Effect는 여전히 + 자유 함수(메소드 아님) — `state` 없이도 성립하는 유스케이스가 있고, + 있어도 leaf 생명주기 바인딩을 `state`가 소유하지 않아서. +- **예전에 기각했던 "Observer에 cleanup 반환 계약 추가"와 안 부딪힘** — + 그때 기각한 건 "Observer 자체에 이 복잡도를 넣지 말자"였지 패턴 자체가 + 무용하다는 게 아니었음. Effect가 opt-in 상위 계층으로 이 패턴을 제공하는 + 지금 구조가 그 기각과 정확히 양립함. +- **`fn`을 커링 스타일(팩토리가 실제 fn을 만들어 반환)로 짜는 것도 Effect/ + Observer 둘 다 모듈화 관용구로 권장** — `Modifier`의 `Boldify(10)` 커링과 + 같은 결. +- **백로그로만 기록, 결정 안 함**: `state:Apply(...)`처럼 여러 개를 커링으로 + 받아 `:With`/`:Compute` 등록을 자동화하는 조합기 아이디어(사용자 제안, + `Modifier:Apply`의 State판 대응물) — `base/bind-system-plan.md`에 백로그 + 절로만 남김, 시그니처/필요성 미검증. +- 이걸로 `question.md` 0번(추가 프리미티브 논의)의 열린 항목은 "키 기반 + 동적 컬렉션 재조정" 하나만 남음. + **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 이미 설계된 것의 세부 마무리라 M0 착수 우선순위 자체는 그대로. From b7ce11cf7f9a443cff1e9e7f2d0e136dede53215 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 16:25:20 +0900 Subject: [PATCH 38/62] =?UTF-8?q?docs:=20=EC=9D=B4=EB=B2=88=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EA=B2=B0=EC=A0=95=EC=82=AC=ED=95=AD=20=EC=BD=94?= =?UTF-8?q?=ED=8D=BC=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EB=B0=8F=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션 clear 전 정합성 점검 — 이미 커밋된 결정들이 다른 문서에 제대로 퍼져있는지 확인하고 빠진 곳을 보강: - ROADMAP.md M3에 Observer(즉시실행 확정)/Effect(fn, state?) 체크박스 추가(그동안 base 문서에만 있고 로드맵엔 전혀 없었음), M8 Ref 체크박스를 :Set/:Callback/:Wait API + 파일 분리 + resume payload 정정 내용으로 갱신 - base/architecture.md 소스트리에 Modifier.luau/Blocker.luau/Effect.luau가 통째로 누락돼 있던 것 추가(Ref.luau 코멘트도 최신 API로 갱신은 이미 완료) - README.md의 effect-plan.md 요약이 "Observer와 관계 미해결"로 남아있던 것 정정 - documentation-content-map.md의 "아직 문서화 보류" 목록에서 해소된 Effect/Observer 항목 제거 - CLAUDE.md 다섯 번째 세션 절에 당시 기록 안 됐던 Override 서브타입 미검증 이슈(modifier-plan.md 9-2번) addendum 추가 Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 2 +- .claude/base/architecture.md | 7 ++++-- .claude/research/documentation-content-map.md | 2 -- CLAUDE.md | 11 +++++++++ ROADMAP.md | 24 ++++++++++++++----- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index fbdd944..172e211 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -35,7 +35,7 @@ | `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` — leaf 죽음에 확정 정리, 재실행 개념 없음. Observer와의 관계가 아직 미해결(문서 내 "미해결" 절, `question.md` 0번) | +| `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` 참고 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index ae86998..b7d2471 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -122,9 +122,12 @@ quad/ ├── quad-base/ │ ├── wally.toml │ └── src/ -│ ├── Source.luau # 값의 근원, 단일 지점 -│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기 +│ ├── 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`/`Override`(`base/modifier-plan.md`) +│ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Dispatch/ │ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔 │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index c05bcbb..f62f006 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -204,8 +204,6 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: - 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) -- Effect가 `state:Effect()`로 Observer를 확장하는 형태인지, 완전히 독립된 - free function인지 (`base/effect-plan.md`의 "미해결" 절) 이 항목들은 `.claude/question.md`에도 이미 열린 질문으로 잡혀있음 — 여기선 "확정 전엔 문서화 대상 아님"이라는 표시만 겸함. diff --git a/CLAUDE.md b/CLAUDE.md index dd68667..164e569 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -832,6 +832,17 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 기각. 전부 clone하는 현재 방식 유지 확정. `base/modifier-plan.md` 9-1번 (a-1) 절. +**같은 세션 네 번째 후속(당시 CLAUDE.md에 미기록 — 2026-08-07 여섯 번째 +세션에서 뒤늦게 발견/보강) — `Override`가 서브타입 관계인 Modifier끼리 +섞일 때의 타입 시그니처는 미검증으로 열어둠.** `FrameModifier`가 +`GuiObjectModifier`의 서브타입이어야 자연스러운데, 필드 setter 메소드의 +리턴 타입이 각자 자기 자신이라(`self`) 단순 구조적 서브타이핑만으로 +`Modifier.Override(guiObjectMod, frameMod)`류가 통과하는지 추론만으로는 +결론 못 냄 — 후보안(메소드 필드는 `any`로 뭉개고 데이터 필드만 구조적 +체크)을 실 Luau로 검증 필요, 안 되면 `Override(...: any): any`로 +느슨하게 열고 이 항목으로 되돌아오는 걸 fallback으로 남김. +`base/modifier-plan.md` 9-2번, `ROADMAP.md` M7에 체크박스 반영 완료. + **다음 세션이 할 일**: 안 바뀜(위 2026-08-06 네 번째 세션 절 참고, `ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index b267f66..bea811c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,6 +67,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) +- [ ] `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 구현 이후에 착수(의존 관계) - [ ] mock 대상 테스트 ## M4 — 첫 end-to-end 반응형 업데이트 @@ -111,12 +118,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M8 — Ref -- [ ] `CreatedRef` 메커니즘(숫자 슬롯 참가자) + `PreRef`(children 배열 - 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — - `base/bind-system-plan.md` "`phase` 옵션 폐기 → 위치로 표현, - `PreRef` 신설" 절) -- [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 resume+소진, - 함수면 호출+유지 — 같은 배열 하나로 통합) +- [ ] `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 모양" 절) +- [ ] `CreatedRef` 메커니즘(숫자 슬롯 참가자) +- [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 + `coroutine.resume(v, self)`+소진, 함수면 `v(value)` 호출+유지 — + 같은 배열 하나로 통합). `:Wait(thread?)`는 `thread`가 `nil`이면 + `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` + 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) - [ ] `LifetimeHandle` quad-roblox 실제 구현(Instance 생존 확인, 인터페이스 자체는 M2로 이동됨) - [ ] `PerInstanceState` quad-roblox 실제 구현(weak-keyed table, 인터페이스 From 71729db8165af2f81c27a81d9922c491130a2166 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 16:59:40 +0900 Subject: [PATCH 39/62] =?UTF-8?q?decide(base):=20Compute=20=EC=BB=A4?= =?UTF-8?q?=EB=A7=81,=20state:Apply=20=ED=99=95=EC=A0=95,=20Effect=20Subsc?= =?UTF-8?q?ribe/Unsubscribe,=20=EC=9D=B4=EC=A4=91=20=EB=B0=94=EC=9D=B8?= =?UTF-8?q?=EB=94=A9=20=EA=B8=88=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :Compute(fn)에도 Observer/Effect와 동일한 커링 권장 노트 추가 - state:Apply(factory) 확정 — ":With"/":Compute" 자동 등록 조합기였던 백로그안 기각, Modifier:Apply와 동일한 순수 체이닝 설탕으로 재정의 - EffectHandle:Subscribe()/:Unsubscribe() 신설 — leaf 없이 쓰는 독립 Effect 지원, :Unsubscribe()는 마지막 cleanup을 1회 트리거해야 함 - Observer/Effect 이중 바인딩(leaf 부착 + 수동 Subscribe) 금지 확정, Bound 플래그로 즉시 error - ROADMAP.md M3/question.md에 반영, effect-plan.md 오기 정정 --- .claude/base/bind-system-plan.md | 107 +++++++++++++++++++++++++++++-- .claude/base/effect-plan.md | 46 +++++++++++++ .claude/question.md | 4 ++ CLAUDE.md | 57 +++++++++++++++- ROADMAP.md | 12 +++- 5 files changed, 217 insertions(+), 9 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 7706e07..7d4718d 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -515,6 +515,13 @@ Connect가 도는 숨은 churn 비용도 있음(Store Set은 dedup 안 함, lazy State 핸들로 통일, 아래 "Store/State/Source 온톨로지" 절의 "`:With`/ `:Compute`" 부분 참고). +**`fn`을 커링 스타일로 짜는 것도 권장(2026-08-07 일곱 번째 세션)** — +`key:Compute(makeFormatter("ko-KR"))`처럼 팩토리가 실제 `fn`을 만들어 +반환하는 패턴, Observer/Effect의 동일 관용구(아래 "`fn`을 커링 스타일로 +짜는 것도 모듈화 관용구로 권장" 절, `base/effect-plan.md`)와 같은 결 — +`:Compute`가 원래부터 이 셋 중 제일 먼저 있던 자리라 뒤늦게 문서화된 +것뿐, 새 결정이라기보다 이미 있던 패턴을 명문화한 것. + ### `:Compute(fn)`의 선택적 두 번째 인자 — `previous` (무거운 파생 객체 재사용, 2026-08-06) **배경**: `:Compute`의 결과가 그 자체로 무겁고 재생성 비용이 큰 엔진 @@ -630,14 +637,56 @@ retract/Destroy되면 자동으로 정리됨. 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 하면 별문제 없음(사용자 판단). -### 백로그(미확정) — `state:Apply(...)`: `:With`+`:Compute` 등록을 커링으로 자동화 (2026-08-07 여섯 번째 세션, 사용자 제안) +### `state:Apply(factory)` — Modifier와 동일한 순수 체이닝 설탕으로 확정 (2026-08-07 일곱 번째 세션) -Effect/Observer의 `fn` 커링 관용구 논의 중 나온 인접 아이디어 — `Modifier`의 -`:Apply(factory)`(팩토리 체이닝, `modifier-plan.md` 8번)와 비슷하게, 여러 -개를 커링으로 받아 알아서 `:With`/`:Compute` 등록을 대신 해주는 -`state:Apply(...)` 같은 조합기가 있으면 편리할 수 있다는 제안. **지금 결정 -필요 없음 — 백로그로만 기록.** 구체 시그니처/필요성 검증 없음, 나중에 -`:With`/`:Compute` 관용구가 실제로 자주 반복되는 게 확인되면 다시 논의. +**처음 제안됐던 "`:With`/`:Compute` 등록을 커링으로 자동화하는 조합기" +방향은 기각됨 — 사용자가 재확인한 실제 의도는 그보다 훨씬 단순함.** +`Modifier:Apply(factory)`도 매번 새 값을 만들어내는 체이닝 설탕일 뿐이듯, +State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 — +`state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로 +쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한 +정의: `function(self, factory) return factory(self) end`). + +- **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 + 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면, + ```lua + -- Apply 없이: 안쪽(가장 최근에 만든 것)부터 거꾸로 읽어야 함 + local capped = capAt(100)(withLocale(localeStore.locale)(rawScore)) + + -- state:Apply로: 왼쪽에서 오른쪽, 만든 순서 그대로 읽힘 + local capped = rawScore + :Apply(withLocale(localeStore.locale)) + :Apply(capAt(100)) + ``` + 팩토리가 세 개, 네 개로 늘어날수록 앞쪽 버전은 괄호 깊이와 읽는 방향이 + 코드 작성 순서와 반대로 꼬여 diff/리뷰에서 특히 안 좋음 — `:Apply` + 버전은 각 줄이 "그다음 뭘 했는지"를 순서대로 나열하므로 Modifier + 체이닝(`mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify)`)과 읽는 + 방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는 + 자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를 + 직접 호출하는 건 팩토리 작성자 몫. +- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로 + 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 + 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 + 자체가 없음 — 그냥 고정 메소드 하나 추가하는 것. +- **타입은 `factory: (State) -> U): U`로 완전히 열어둠** — Modifier의 + `Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 + 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 + 요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도 + 막을 이유가 없음 — Modifier보다 오히려 더 자유로운 시그니처. +- **Source도 자동 포함**: Source가 State를 구조적으로 만족하는 기존 + 델리게이션(`__index`로 `:With`/`:Compute` 위임)에 `:Apply`도 그대로 + 얹히므로 별도 구현 불필요. +- **Effect/Observer/Compute의 `fn` 커링 권장(위 절들)과 같은 스레드지만 + 별개 기능** — 커링은 "`fn` 자체를 팩토리로 짜는 관용구" 권장이고, + `:Apply`는 그렇게 만든 팩토리를 체이닝 문법으로 적용하는 수단. 둘이 + 합쳐지면 `state:Apply(makeFormatter("ko-KR"))`처럼 자연스럽게 이어짐. + +**Observer/Effect의 `:Subscribe()`/`:Unsubscribe()`는 이 절과 무관한 +별개 주제** — 아래 새 절로 분리(이전에 이 헤더 아래 잘못 걸려 있던 +문서 버그 수정, 내용 자체는 이미 확정된 것 그대로). + +### Observer의 `:Subscribe()`/`:Unsubscribe()` — children 배열 밖 독립 구독 (2026-08-06 후속 세션) **문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 "어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 @@ -692,6 +741,50 @@ Effect/Observer의 `fn` 커링 관용구 논의 중 나온 인접 아이디어 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능. +### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `Bound` 플래그로 즉시 에러 (2026-08-07 일곱 번째 세션) + +**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 +딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 +(위 weak table 경로) `:Subscribe()`로 수동 등록되거나(위 강참조 +레지스트리 경로), 둘 중 하나만. **둘 다 동시에 걸리는 건 UB로 확정** — +이미 leaf에 부착된 핸들을 다시 `:Subscribe()`하는 것도, 이미 +`:Subscribe()`한 핸들을 children 배열에 놓아 leaf로도 부착시키는 것도 +둘 다 금지. + +**UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 +0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 +바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈: + +```lua +-- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인 +if self.Bound then + error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음") +end +self.Bound = true +``` + +- **`Bound`는 가칭** — 용어 정리 라운드에서 최종 이름 재검토 대상 + (`.claude/question.md`에 반영). +- 이 플래그는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 + 표시 — 두 진입점이 똑같이 확인/설정하므로 순서와 무관하게 대칭적으로 + 막힘. +- **`:Unsubscribe()`는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 + 뜻으로 통일** — `Bound`가 어느 경로로 세워졌는지와 무관하게, + `:Unsubscribe()` 한 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 + 강참조 등록이든)을 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 + 자동(리프) 케이스에도 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 + 별개로, "단일 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. +- **Effect도 동일 규칙 적용** — 내부적으로 Observer를 조합하는 경우든 + `state` 없는 경우든 같은 `Bound` 게이트를 그대로 재사용 + (`base/effect-plan.md`). 이전에 그 문서에 적어뒀던 "leaf 부착과 + `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 규칙으로 + 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 하는 + 조합이었음. +- **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children + 배열에 놓았다면 그걸 다시 `:Subscribe()`하지 말 것, 반대도 마찬가지 — + 두 경로를 동시에 쓰고 싶으면 각각 독립된 새 `Effect(...)`/ + `state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + ## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 **배경**: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 3bf439f..4ccecfc 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -69,6 +69,52 @@ end; lastConn = ... end)`) **Observer 자체**가 이걸 대신해줄 이유는 (Effect)으로 분리해 얹었을 뿐, Observer의 기본 계약(재실행 신호만, cleanup은 클로저로 직접)은 그대로 가볍게 유지됨. +## `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와 동일 관용구). +- **`: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 게이트를 공유하니 + 동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩 + 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 + 규칙과 `Bound` 플래그 기반 즉시-에러 메커니즘은 + `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. leaf 부착 + **후** `:Unsubscribe()`로 조기 해제하는 것(위 "Observer의 + `:Unsubscribe()`는 자동 케이스에도 동일하게 씀" 패턴)은 여전히 정상 — + 금지되는 건 leaf 부착과 `:Subscribe()`를 **같이** 쓰는 것뿐. + ## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) **과거 미해결이었던 두 질문 모두 확정**: diff --git a/.claude/question.md b/.claude/question.md index fb56a81..fd416d9 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -87,6 +87,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** Source 판별 predicate 세 개의 이름 — 동작은 전부 확정(`base/ modifier-plan.md` 9번, `base/bind-system-plan.md`의 `isState` 절), 이름만 다른 가칭들과 같이 용어 정리 라운드에서 재검토. +- **`Bound`(3순위, 사소함, 2026-08-07 일곱 번째 세션 추가)**: Observer/ + Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 + 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` + "이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ diff --git a/CLAUDE.md b/CLAUDE.md index 164e569..2ea1303 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -930,9 +930,64 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 - **백로그로만 기록, 결정 안 함**: `state:Apply(...)`처럼 여러 개를 커링으로 받아 `:With`/`:Compute` 등록을 자동화하는 조합기 아이디어(사용자 제안, `Modifier:Apply`의 State판 대응물) — `base/bind-system-plan.md`에 백로그 - 절로만 남김, 시그니처/필요성 미검증. + 절로만 남김, 시그니처/필요성 미검증. **(2026-08-07 일곱 번째 세션에서 + 이 방향 자체가 기각되고 훨씬 단순한 형태로 확정됨 — 아래 참고.)** - 이걸로 `question.md` 0번(추가 프리미티브 논의)의 열린 항목은 "키 기반 동적 컬렉션 재조정" 하나만 남음. **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 이미 설계된 것의 세부 마무리라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 일곱 번째 세션 — `:Compute` 커링, `state:Apply` 확정(백로그안 기각), Effect `:Subscribe`/`:Unsubscribe` 신설, 이중 바인딩 금지 + +짧은 대화형 세션, 네 가지를 순서대로 처리 — 전부 `base/bind-system-plan.md`/ +`base/effect-plan.md`/`ROADMAP.md`/`question.md`에 반영 완료: + +1. **`:Compute(fn)`에도 커링 권장 노트 추가.** 여섯 번째 세션에서 Observer/ + Effect의 `fn`에만 문서화됐던 "팩토리가 실제 `fn`을 만들어 반환하는 + 커링 스타일 권장"이 `:Compute`엔 빠져 있었음 — 같은 결이라 자연스럽게 + 확장, `bind-system-plan.md` "`:With`+`:Compute`" 절에 추가. +2. **`state:Apply(factory)` 확정 — 원래 백로그였던 "`:With`/`:Compute` + 등록을 커링으로 자동화하는 조합기" 방향은 기각.** 사용자가 재확인한 + 실제 의도는 훨씬 단순함: `Modifier:Apply`와 똑같이 `factory(self)`를 + 체이닝 문법으로 부르는 순수 설탕(`function(self, factory) return + factory(self) end`) — `fnb(c,d)(fn(a,b)(state))`처럼 팩토리를 안에서 + 밖으로 겹쳐 읽어야 하는 중첩을 `state:With(a,b):Compute(fn(a,b)) + :Apply(fnb(c,d))`로 펴는 게 유일한 목적. 구현 비용 거의 0(State는 + Modifier와 달리 제네릭 `__index` 필드 setter 합성이 없어 이름 예약 + 충돌도 없음), 타입은 `factory: (State) -> U): U`로 Modifier보다 + 더 열어둠(팩토리가 State 밖 plain 값을 반환해 반응형 그래프를 벗어나는 + 것도 허용). Source는 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 + 포함. `bind-system-plan.md` "`state:Apply(factory)`" 절, 구체 전/후 + 코드 예시까지 반영. 부수적으로 같은 헤더 아래 잘못 걸려 있던 Observer + `:Subscribe`/`:Unsubscribe` 내용(무관한 주제)을 별도 절로 분리하는 + 문서 버그도 수정. +3. **`EffectHandle:Subscribe()`/`:Unsubscribe()` 신설.** 지금까지 Effect의 + 유일한 생애주기 경로는 children 배열 leaf 부착뿐이라, leaf 없이 쓰는 + 모듈/스크립트 레벨 사이드 이펙트(백그라운드 시스템 등)엔 반환된 + `EffectHandle`이 막다른 길이었음 — Observer가 이미 가진 `:Subscribe`/ + `:Unsubscribe`와 같은 결로 확정. **핵심 주의점**: Effect의 + `:Unsubscribe()`는 Observer의 것을 그냥 위임하면 안 됨 — Observer의 + 계약은 "미래 재실행만 끊는다"로 충분하지만, Effect의 계약은 "생애주기가 + 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"이고 leaf 사망은 + 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 동일하게 "지금 + 끝났다"는 신호로 취급해 마지막 cleanup을 트리거해야 계약이 일관됨(leaf + 가 살아있어도 마찬가지). idempotent 보장은 기존 `Subscribed` 필드 + liveness 체크 재사용으로 공짜. `base/effect-plan.md` 신규 절. +4. **Observer/Effect 이중 바인딩 금지 — `Bound`(가칭) 플래그로 즉시 + `error`.** 처음엔 "leaf 부착과 `:Subscribe()`를 동시에 써도 같은 + liveness 게이트를 공유하니 안전"이라고 적었으나, 사용자가 애초에 한 + 핸들은 라이프사이클 바인딩 경로를 하나만 가져야 한다고 정정 — 동시 + 바인딩은 UB로 확정하되, 판별 비용이 사실상 0(불리언 필드 하나)이라 + 조용한 오동작 대신 그 자리에서 `error`를 던지는 쪽으로 결정 + (엔지니어링 비용 대비 디버깅 이득이 명확). 두 진입점(`:Subscribe()` + 호출부, children 배열 leaf 부착부)이 똑같이 확인/설정하는 대칭적 게이트 + — 순서 무관. `bind-system-plan.md` "이중 바인딩 금지" 절 신설, + `effect-plan.md`의 3번 항목 서술은 이 규칙으로 대체(정정 표시 남김). + +**부수 정리**: `ROADMAP.md` M3에 `state:Apply`/Effect `:Subscribe`· +`:Unsubscribe`/이중 바인딩 금지 체크박스 추가. `question.md`에 `Bound` +이름을 용어 정리 대상(3순위)으로 추가. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index bea811c..216543f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,13 +67,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) +- [ ] `state:Apply(factory)`(`base/bind-system-plan.md` "`state:Apply(factory)`" + 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 + 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 + 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 - [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회 실행 확정**(`base/bind-system-plan.md`의 Observer 절), `isObserver` 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` - [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치 1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React - `useEffect` 동형). Observer 구현 이후에 착수(의존 관계) + `useEffect` 동형). Observer 구현 이후에 착수(의존 관계). + `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 + 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 + 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) +- [ ] Observer/Effect 이중 바인딩 금지 — `Bound`(가칭) 플래그로 leaf 부착과 + `:Subscribe()`가 동시에 걸리면 즉시 `error`(`base/bind-system-plan.md` + "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션) - [ ] mock 대상 테스트 ## M4 — 첫 end-to-end 반응형 업데이트 From d947abf17a9285fb461dd3ff4b21dacaa433bf05 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 18:10:58 +0900 Subject: [PATCH 40/62] =?UTF-8?q?decide(base):=20None=20=EC=84=BC=ED=8B=B0?= =?UTF-8?q?=EB=84=90,=20Dispatch=20=EB=84=A4=EC=9D=B4=EB=B0=8D,=20Brand=20?= =?UTF-8?q?=ED=8C=90=EB=B3=84=20=EB=A9=94=EC=BB=A4=EB=8B=88=EC=A6=98=20?= =?UTF-8?q?=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modifier 필드를 인라인 키/setter로 명시적으로 지우는 `None` 센티널 확정 — merge는 안 바뀌고, 디스패치 쪽 NoneHandler가 Tween store-bind와 같은 재귀 재디스패치로 처리(base 드라이버/개별 핸들러 시그니처 불변). - Dispatch.getHandler/process/addHandler/drive로 오케스트레이터 이름 공식화, isHandlable에 inst 추가, canExecute 시그니처를 (handle)->boolean 으로 정정(zero-arg 클로저 폐기). - isState를 Brand 공유 레지스트리로 일반화해 isObserver/isSource/isTag 등 10종 판별자로 확장, isSource 별도 필요하다고 정정. - Tag/Attribute retract 불필요함을 확인, 전용 문서(tag-plan.md/ attribute-plan.md) 신설. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 4 +- .claude/base/attribute-plan.md | 72 +++++++ .claude/base/bind-system-plan.md | 297 +++++++++++++++++++------- .claude/base/lifecycle-pattern.md | 30 ++- .claude/base/modifier-plan.md | 55 +++-- .claude/base/module-lifecycle-plan.md | 8 +- .claude/base/tag-plan.md | 46 ++++ .claude/base/ui-shorthand-plan.md | 20 ++ .claude/question.md | 27 ++- CLAUDE.md | 171 +++++++++++++++ ROADMAP.md | 50 ++++- 11 files changed, 658 insertions(+), 122 deletions(-) create mode 100644 .claude/base/attribute-plan.md create mode 100644 .claude/base/tag-plan.md diff --git a/.claude/README.md b/.claude/README.md index 172e211..a4cb785 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -36,7 +36,9 @@ | `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` 참고 | +| `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-07 여덟 번째 세션 신설]** `[Tag "Name"] = boolean` — `CollectionService` 얇은 래퍼, `process`가 add/remove 전부 처리, `retract` 불필요 | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute` vs `BooleanAttribute`류)만 미확정 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md new file mode 100644 index 0000000..d147134 --- /dev/null +++ b/.claude/base/attribute-plan.md @@ -0,0 +1,72 @@ +# Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기 + +**상태**: base(메커니즘/`None`/`retract` 동작은 확정) — 타입 파라미터화 +이름만 미확정. `[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 타입 +지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐). + +**후보 두 가지 (미확정)**: +- `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — + 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. +- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 + 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ + `InstanceAttribute` 등). + +**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 +패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 +구조적으로 똑같은 문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 +필드로 나눌지" 고민이 있었고, 결론은 **둘 다**(`new(className)` +제네릭 생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 +같은 모양을 재사용하면 자연스러울 가능성 — `Attribute("name")` 제네릭을 +기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ +`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/ +`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 사용자 확인 전 +소견일 뿐 — `.claude/question.md`에 반영, 사용자 판단 필요. + +## 메커니즘, `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`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜", + Tween↔일반 프로퍼티가 실사례)에 해당 안 함 — `bind-system-plan.md` + "확정된 디스패치 모델" 절이 한때 Attribute도 retract 필요 예시로 들었던 + 걸 여기서 바로잡음. +- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store`/`State` + 값도 받음). + +## 패키지 배치 + +UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접 +포함, 별도 opt-out 패키지로 안 쪼갬. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 타입 파라미터화 이름(`Attribute` 제네릭 vs `BooleanAttribute`류 정적 + 패밀리 vs 절충) — 위 "문제" 절 참고, 다음 세션 사용자 판단 필요. diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 7d4718d..a3594b4 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -24,12 +24,20 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 핸들러는 다음 4개를 제공하는 등록 가능한 객체: -- `isHandlable(key, value): boolean` — 이 핸들러가 이 key/value 쌍을 처리할 - 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — tbox의 type-check/ - constraint-check 분리 원칙(`.claude/initreq/tbox/CLAUDE.md`의 "타입 체크는 - 분기 선택에 쓰이므로 순수해야 함")을 그대로 적용: `isHandlable`은 오직 - "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 유효성 검사는 핸들러가 - 선택된 *이후* 별도 단계에서. +- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value + 조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — + tbox의 type-check/constraint-check 분리 원칙(`.claude/initreq/tbox/ + CLAUDE.md`의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로 + 적용: `isHandlable`은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 + 유효성 검사는 핸들러가 선택된 *이후* 별도 단계에서. **`inst`도 받음 + (2026-08-07 여덟 번째 세션 정정, 원래 `(key,value)`뿐이었음)** — + `process`/`retract`는 처음부터 항상 `inst`를 받았는데("모든 핸들러는 + 대상 Instance를 직접, 항상 받는다", 아래 "확정된 디스패치 모델" 절) + `isHandlable`만 예외였던 게 애초에 약간의 불일치. 지금 당장 `inst`에 + 따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른 + 백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러 + 계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 — + 사용자 판단으로 확정. - `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 action() 우선순위)보다 일반화된 **열린 숫자 공간**으로. - `process(inst, key, value)` — 실제 처리 수행(아래 "확정된 디스패치 모델" @@ -62,8 +70,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 — base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량. -- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, `isHandlable(k,v)`를 - 만족하는 최상위 핸들러가 실제 처리를 담당. +- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, + `isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당. + **이 "스캔+실행" 오케스트레이터는 `Dispatch.process`로, 순수 스캔 + 부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널 + 절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상 + 그냥 `process`로 계속 씀. - 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 store이면 그 값을 처리(구독)함. 이 핸들러 안에서: @@ -72,11 +84,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 그 시점엔 그냥 `Connected`를 보고 무시(no-op). 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. - 3. **`realv`를 들고 다시 `process(inst, k, realv)`를 재귀 호출** — 이게 바로 - "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 이 문서 이전 - 초안의 결론과 일치. `realv`가 store가 아니라면 자연히 Tween의 store-bind - 핸들러 `isHandlable`을 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 - 세터 등)로 흘러감 — 무한 재귀 걱정 없음. + 3. **`realv`를 들고 다시 `Dispatch.process(inst, k, realv)`를 재귀 호출** + (오케스트레이터 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 + 여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 + 재실행하는 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 + store가 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 + 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 + — 무한 재귀 걱정 없음. - **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — @@ -84,9 +98,17 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. - - `retract`가 실제로 의미 있는 곳: **Tag를 지운다, Attribute 엔트리 자체를 - 지운다, 실행 중인 Tween을 멈춘다** 같은, "값을 새로 셋하는 것"과 - "이전 상태를 명시적으로 되돌리는 것"이 다른 케이스. + - **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는 + 핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째 + 세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행 + 중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반 + PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게 + `retract`의 일. **Tag/Attribute는 여기 해당 안 함** — 처음엔 이 + 둘도 예시로 들었으나, 실제로는 UICorner 숏핸드와 같은 패턴(값의 + 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 담당하고, + 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 안 + 일어나서 `retract`가 발화할 조건이 생기지 않음 — 구체 설계는 + `base/tag-plan.md`/`base/attribute-plan.md`. - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를 한 번 호출해주면 됨. - **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 @@ -135,6 +157,92 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). +### `None` 센티널 — Tween store-bind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션) + +`modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 +지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는 +`None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 +층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 +Tween store-bind 핸들러(65-79행)와 완전히 같은 모양의 핸들러 하나 추가.** + +``` +NoneHandler.priority = <매우 높음> +NoneHandler.isHandlable(inst, k, v) = (v == None) +NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 +``` + +- **매치 predicate는 `isHandlable`** — `canExecute`가 아님. 둘은 완전히 + 다른 개념이라 혼동하지 말 것: `isHandlable(k,v)`는 KV 매치 predicate(핸들러 + 계약 4종 중 하나, 이 절에서 다루는 것), `canExecute`는 인자로 받은 특정 + 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 + 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — + KV 매치와 무관. + `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 + `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 + 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. + `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 + 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 + 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — Tween의 + store-bind 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 + 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. +- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 + 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 + "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 + 로직" 둘 다 그냥 `process`라고 불러서 이름이 겹쳤음 — 이제 두 계층을 + 명시적으로 분리: + - `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+ + `priority`), 부작용 없음. + - `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 → + 이전에 이 키를 담당하던 핸들러와 다르면 이전 핸들러의 `retract` 호출 → + 새로 매치된 핸들러의 `.process` 호출. **재귀 재디스패치(Tween/일반 + store-bind/`NoneHandler`)는 전부 이 `Dispatch.process`를 다시 부르는 + 것** — 원래 있던 재귀 관례 그대로, 새로 바뀐 것 없음. + - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 + 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 + 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ + UICornerHandler/TagHandler/AttributeHandler 등)은 팩토리가 `BaseModule`을 + 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 + 같은 패턴, 새 메커니즘 아님). + - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, + `question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — + 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 + 소유자를 명시(`Dispatch.process` vs `handler.process`)하는 것으로 해소. + - **base 드라이버 루프 자신의 이름은 `Dispatch.drive(inst, flattened)`로 + 확정** — 이미 위 "props 순회 순서" 절이 이걸 비공식적으로 "base + 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를 + 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와 + flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 + 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에 + `Dispatch.process`를 호출하는 게 이 함수의 본체. +- **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은 + "리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라 + 사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔 + 자식 Instance를 실제로 지우는 것까지 포함 — 구체 예시는 + `base/ui-shorthand-plan.md`/`base/tag-plan.md`/`base/attribute-plan.md`. + `None`은 **"이 조합 단계에서 나는 이 필드를 세팅 안 한다"**는 뜻이고, + 그걸 받은 실제 핸들러가 무엇을 할지는 각자 몫. 개별 프로퍼티/이벤트/UI + shorthand 핸들러의 `process` 시그니처는 안 바뀜 — 이들은 원래도 `v`가 + State 계산 결과로 `nil`이 되는 경우를 처리할 수 있어야 했으므로(일반 + 반응형 케이스), `None`은 그 기존 경로에 도달하는 방법 하나가 늘어난 것뿐. + **구현 디테일 캐비엇**: `None→nil`이 Roblox의 nil을 허용 안 하는 타입 + 프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 — + PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야 + 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸). +- **retract와는 무관** — `retract`는 "같은 키를 다른 *핸들러 타입*이 + 넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지 + "`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상 + `Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`가 + 딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로). +- **M2 착수 시 확인할 것 (`pre-implementation-audit.md` 우선순위1 + "이전에 실제로 매치됐던 핸들러 추적" 항목에 추가)**: "이 키를 지금 누가 + 담당 중인가" bookkeeping은 바깥 순회 루프(`Dispatch.drive`)가 아니라 + `Dispatch.process` 호출 자체 내부에서 갱신돼야 함. 안 그러면 값이 계속 + `None`으로 유지되는 매 사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤 + 실제 담당은 다른 핸들러"로 바깥 루프가 오판해 불필요한 `retract`를 반복 + 호출할 위험이 있음 — `Dispatch.process`가 재귀 호출 시에도 자기 자신을 + 통해 담당자 기록을 갱신하게만 해두면(`Dispatch.drive`가 별도로 기록 안 + 하고 `Dispatch.process` 내부에 위임) 자연히 해소됨. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -142,10 +250,11 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 **확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을 -따르되, `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 value)로 -`process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. 이러면 store 값 -자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) 상관없이 동일한 -재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결. +따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 +value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. +이러면 store 값 자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) +상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 +가능한가"와 직결. Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 @@ -454,9 +563,9 @@ SyntheticEvent만 주는 것과 같은 모양). 바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. **엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable -바인드를 재실행하는 래핑"(위 절, `process`가 값이 바뀔 때마다 -`process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 같이 -호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. +바인드를 재실행하는 래핑"(위 절, 핸들러의 `process`가 값이 바뀔 때마다 +`Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 +같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. 이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을 per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/ @@ -1155,8 +1264,8 @@ copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기 ## 확정된 것 (더 이상 열린 질문 아님) -- **핸들러 계약**: `isHandlable(k,v)` + `priority` + `process`(구 `bind`) + - `retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 +- **핸들러 계약**: `isHandlable(inst,k,v)` + `priority` + `process`(구 + `bind`) + `retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장). - **Signal 클래스**: 안 만듦, 콜백 + `Connected` 계산 속성만(`base/ lifecycle-pattern.md`). @@ -1245,71 +1354,86 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 - **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지** (위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). -## Attribute 특수 키 — 타입 파라미터화 (2026-08-06, 신규 논의) +## Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션) -**상태**: 미확정, 사용자가 이번에 새로 제기 — 이전에 기록된 적 없음 -(`architecture.md` 4번 항목의 `[Attribute "Name"]`은 특수 DI 키의 존재만 -확정했을 뿐, 타입을 어떻게 표현할지는 다룬 적 없었음). +`base/tag-plan.md`/`base/attribute-plan.md`로 이동 — 이 절이 다루던 타입 +파라미터화 문제(`[Attribute<> "name"]` vs `[BooleanAttribute +"name"]`)뿐 아니라 `None`/`process`/`retract` 동작까지 확정 반영됨. +UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야 한다는 +지적으로 분리. -**문제**: 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 타입 지원까지 감안하면 그 결정의 근거가 -한층 더 탄탄해짐). +## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션) -**후보 두 가지**: -- `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — - 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. -- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 - 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ - `InstanceAttribute` 등). +**배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<>(key): +T|State|nil`가 돌려주는 raw union을 사용자 코드가 분기하려면 판별 +수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부 +적용됨 — `Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ +`Source`/`Slot`/`None`까지, Handler 구현(`isHandlable`에서 "이 값이 +Tween인가/Store인가" 판별)과 사용자 코드 양쪽에서 반복적으로 필요해질 +수단이라 `isState` 하나만 만들고 끝내지 않고 전체를 일관된 메커니즘으로 +통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 +(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). -**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 -패턴(위 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 -문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 필드로 나눌지" -고민이 있었고, 결론은 **둘 다**(`new(className)` 제네릭 -생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 같은 -모양을 재사용하면 자연스러울 가능성 — `Attribute("name")` 제네릭을 -기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ -`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/ -`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 단 이건 사용자 -확인 전 소견일 뿐 — `.claude/question.md`에 반영, 다음 세션에서 사용자 -판단 필요. - -## `isState(x): boolean` — State/Source 판별 predicate (2026-08-07 다섯 번째 세션) - -**배경**: `base/modifier-plan.md` 9번 절의 `:Peek<>(key): T|State|nil` -(Modifier 필드를 확정하지 않고 raw 그대로 읽는 접근자)이 나오면서, 사용자 -코드가 그 결과를 State/plain으로 분기하려면 판별 수단이 필요해짐 — 같은 -필요가 실은 새로 생긴 게 아니라 Modifier의 함수형 setter(`modifier-plan.md` -4-1번, "현재 필드가 State냐 plain이냐"로 동작이 갈림)가 지금까지도 내부적으로 -풀어야 했던 문제인데 그 판별 방법 자체가 문서에 명시된 적이 없었음 — 이번에 -`isState`로 명문화하며 그 구멍도 같이 메움. - -**Source도 같이 잡힘, 별도 `isSource` 불필요** — Source가 State를 구조적으로 -만족(위 "Source가 State를 만족함" 관련 내용은 `base/store-semantics.md` -참고)하므로, `isState(source) == true`가 자연스러운 동작이고 그걸로 충분함. - -**구현: weak-key 레지스트리, duck-typing 아님.** +**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 +사용(문자열 아님).** ``` +local Brand = {} local registry = setmetatable({}, {__mode = "k"}) --- State/Source를 만드는 모든 생성 지점(Source(...), :With(...), :Compute(fn) 등)에서: -registry[newHandle] = true --- predicate: + +function Brand.set(x, tag) registry[x] = tag end +function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 + +-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님 +local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag, + StateTag, SourceTag, StoreTag, SlotTag = {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + +-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: +Brand.set(newHandle, ObserverTag) +``` + +**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** — +Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 +무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열 +리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 +레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 +되어 헷갈릴 여지가 없음. + +**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — +단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 +관계(subtype)가 있어 집합 멤버십이 필요한 경우(`isState`)로 갈림: + +``` local function isState(x) - return registry[x] == true + local t = Brand.get(x) + return t == StateTag or t == SourceTag -- Source가 State를 구조적으로 만족 +end +local function isSource(x) + return Brand.get(x) == SourceTag end ``` +**정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 +뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만 +염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을 +가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는 +원천인가"를 알아야 하는 코드는 `isState`만으론 부족함 — `isSource`를 +별도로 제공, `isState`는 여전히 `{State, Source}` 둘 다 통과시킴(상위 +개념이니까 당연히). `component-composition-plan.md` 4번 절이 이미 +`isSource`가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두 +문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource` +불필요" 서술도 같이 정정 대상. + +**`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** +`Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 +quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` +레퍼런스 비교가 더 싸고 정확함. 다만 `Brand.get(x)`가 "quad가 아는 모든 +값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가 +"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 `None`도 +빠지면 안 되므로, `Brand.get`이 내부적으로 `x == None`을 먼저 확인하는 +특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`은 +바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음). + **duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의 값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를 @@ -1317,10 +1441,21 @@ end 인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한 엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 -같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 State/ -Source가 GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 +같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이 +GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격). +**Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자 +확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가 +`v`의 정적 타입을 알아서 좁혀주진 않음(TypeScript의 `x is T`류 사용자 +정의 타입 가드를 Luau가 지원 안 함) — `if isState(v) then local s = v :: +State ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 게 실제 +패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만 +"자동 narrowing"을 기대하면 안 됨. + +**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상, +`.claude/question.md`에 반영.** + ## 남은 열린 질문 (`.claude/question.md`에도 취합) 이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 020495b..776a987 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -127,9 +127,33 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 **base가 범용 유틸로 제공할 것: 임의의 클로저/구독을 실제 Roblox 객체의 생명주기에 바인드하는 도구** — 내부적으로 v1/rbvm이 쓰던 "connect 트릭"(어떤 신호에든 연결해서 참조를 죽을 때까지만 붙잡아두는 것)을 그대로 재사용. 이 -도구로 바인드된 옵저버는 `canExecute: () -> boolean` 같은 predicate 람다를 -가질 수 있어서, `Connected`가 false면 실행 자체를 건너뛸 수 있음(죽은 대상에 -대한 처리 시도 방지, 위 원칙과 직결). +도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 +실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). + +**`canExecute`의 시그니처는 `(handle) -> boolean`이지 `() -> boolean`이 +아님(2026-08-07 여덟 번째 세션, 정정)** — 처음엔 "바인딩마다 클로즈오버된 +zero-arg 람다"로 적었으나, 그러면 등록마다 클로저를 새로 만들어야 해서 +아래 "base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절의 패턴 +(base는 타입만 갖고, quad-roblox가 `BaseModule`을 뮤테이션해서 실 구현체를 +채워넣음)과 잘 안 맞음 — 그 패턴이 성립하려면 `canExecute`는 **quad-roblox가 +한 번만 주입하는 공유 함수**여야 하고, 그러려면 "어떤 등록을 확인할지"를 +가리키는 인자(`handle`, 아래 gchold 스케치의 Connection이 이 역할)가 있어야 +함. base 입장에선 `handle`은 `any`(엔진마다 실체가 다를 수 있음). + +**quad-roblox 구현 스케치(rbvm 패턴 재사용, base 결정 아님 — 참고용)**: +Instance당(꼭 하나일 필요는 없지만 보통 그게 싸서 하나로 감) weak-keyed +per-instance 저장소(`base.perInstanceState(inst)`)에 "gchold" 배열을 둠. +그 배열엔 절대 발화하지 않도록 골라 만든 신호에 연결한 Connection을 +넣는데, 이 Connection의 콜백 클로저 안에 실제로 살려두고 싶은 옵저버를 +업밸류로 캡쳐해둠(콜백은 안 불려도 클로저 자체가 살아있는 한 업밸류는 +안 죽음) — `inst`가 GC되면 gchold 배열째로 같이 죽으므로 옵저버도 자연히 +GC됨(`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 weak-keyed +중첩 구조와 같은 원리). `canExecute(handle)`은 이 Connection(또는 이를 +감싼 핸들)을 받아 `.Connected`를 확인하는 정도로 구현될 것. +**미확인 세부사항**: 옵저버 → Connection 역참조를 별도 weak 릴레이션으로 +둘지, 아니면 그냥 Observer 테이블 안 평범한 필드로 넣을지(정적 해싱된 +필드 접근이 weak 테이블 조회보다 싸서 후자가 나을 수 있음) — quad-roblox +구현 단계에서 실측 확인 필요, base 설계에 영향 없음. 이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"(weak-keyed per-instance diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index ae9eb76..03ddfba 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -46,22 +46,46 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두 규칙으로 쪼개야 함. -### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기"는 아직 안 풀림 — `None` 센티널 후보만 메모 (2026-08-07 세 번째 세션, 미확정) +### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기" — `None` 센티널로 확정 (2026-08-07 여덟 번째 세션) -**문제**: `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 +**문제**: `{ TextColor3 = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 명시적으로 취소하고 싶어도, Lua 테이블 리터럴에서 `키 = nil`은 그 키 자체가 아예 존재하지 않는 것과 구별이 안 됨(`pairs`에서도 안 보임) — 그래서 위 2번 "인라인은 무조건 우선" 규칙이 실제로 작동할 근거(인라인 키가 -존재한다는 사실 자체)가 사라지고, `mod`가 주는 `Override` 값이 그대로 -새어나옴. +존재한다는 사실 자체)가 사라지고, `mod`가 주는 값이 그대로 새어나옴. -**후보(미확정)**: 이벤트 store-bind에서 이미 쓴 "`nil` 대신 실재하는 -센티널 값" 패턴(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 -store-bind 가능" 절) 재사용 — `None`(가칭) 프리미티브를 만들어 -`{ Override = None, mod }`로 쓰면 flatten 로직이 `None`을 만난 인라인 키를 -"명시적으로 지움"으로 해석해 modifier 쪽 값을 덮어씀. 상세 설계(타입, -flatten 내부 표현, State 필드에도 같은 문제가 적용되는지 등)는 다음 -세션에서 이어감 — 지금은 문제와 방향성만 기록. +**결론 — `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/Override/인라인 props 테이블은 `None`을 그냥 평범한 raw + 값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이 + 테이블에 존재하는 값으로 표현이 안 된다"는 것뿐이라, 표현 가능한 실재 + 센티널만 있으면 기존 merge 규칙("인라인 키 존재 시 무조건 우선", + `Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. + 구현 비용이 사실상 0이라 인라인 키/setter 둘 다 여는 데 주저할 이유가 + 없음(2026-08-07 여덟 번째 세션 확정) — setter로 받으면 "특정 필드만 지우는 + 재사용 가능한 modifier 조각"(9-1번의 스타일 프리셋 opt-out 시나리오)도 + 공짜로 됨. +- **`:Peek<>(key)`의 반환 타입이 `T | State | None | nil`로 확장됨** — + `Peek`은 raw 저장값을 그대로 읽으므로(9번 절 "현재 저장된 그대로 넘김" + 원칙) `None`을 다른 값처럼 있는 그대로 돌려줌. "필드가 아예 안 채워짐" + (`nil`)과 "명시적으로 지워짐"(`None`)은 raw 계층에서 계속 구별됨. +- **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난 + 뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게 + 처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다. + 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — Tween store-bind와 + 같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`도 + Tween의 store-bind 핸들러와 완전히 같은 모양(`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 기반 체이닝 @@ -445,9 +469,12 @@ setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방 **`isState(x): boolean` 필요 — `base/bind-system-plan.md`에 정의**. `Peek`가 raw union을 돌려주므로 사용자 코드가 State/plain을 분기하려면 판별 수단이 필요함(Source가 State를 구조적으로 만족하므로 `isState`가 -Source도 같이 잡아줌 — 별도 `isSource` 불필요). 상세 근거/구현 방식은 -`bind-system-plan.md`의 `isState` 절 참고 — 요지만: duck-typing 대신 -weak-key 레지스트리 기반, 그리고 이 판별 로직 자체는 새로 만드는 게 +Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSource`도 +별도로 존재함**, `:Set`/`:Emit` 같은 Source 전용 능력이 있는지 알아야 +하는 코드를 위해 필요하다고 판단 정정됨). 상세 근거/구현 방식은 +`bind-system-plan.md`의 `Brand`/`isState` 절 참고 — 요지만: duck-typing +대신 weak-key 레지스트리 기반(quad의 다른 branded 타입 전부와 공유하는 +통합 메커니즘으로 일반화됨), 그리고 이 판별 로직 자체는 새로 만드는 게 아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로 승격하는 것뿐. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 4ed563e..7a40c72 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -45,9 +45,11 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 **사용자 확인 완료**: base가 `LifetimeHandle` 추상화(생명주기/`Connected` 계산 속성)를 소유하는 게 맞다고 확정. 추가로 명확해진 것 — **store 바인드가 -수행하는 "처리된 값을 다시 `process(inst,k,realv)`로 넘기는" 재실행 로직 -자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 재구현하면 안 -됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → `base/bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 제공 로직. +수행하는 "처리된 값을 다시 `Dispatch.process(inst,k,realv)`로 넘기는" 재실행 +로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 +재구현하면 안 됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → +`base/bind-system-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이 +바로 이 base 제공 로직. 부수적으로 확인된 것: - **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md new file mode 100644 index 0000000..e116ce9 --- /dev/null +++ b/.claude/base/tag-plan.md @@ -0,0 +1,46 @@ +# Tag 특수 키 — `CollectionService` 얇은 래퍼 + +**상태**: base — `[Tag "Name"] = true` DI 키의 존재 자체는 `architecture.md` +4번 항목에서 이미 확정. 이 문서는 UICorner 숏핸드(`base/ui-shorthand-plan.md`)/ +Tween(`research/tween-plan.md`)처럼 별도 전용 문서가 없던 걸 2026-08-07 +여덟 번째 세션에 메꾼 것 — Tag/Attribute도 "1 프리미티브 1 파일" 관례 +(Blocker/Effect/Ref/PreRef 분리 선례)를 따라야 한다는 사용자 지적으로 신설. +새 설계 내용은 없음 — 이미 여기저기 흩어져 있던 결정을 한 곳에 모으고, +오늘 논의한 `None`/`process`/`retract` 동작을 반영. + +## 값 모양 + +`[Tag "Name"] = boolean | State` — store-bind 가능(일반 프로퍼티와 +동일하게 취급). PA님의 `EventDrivenProgramming/Observer.luau` +`subscribeTaggedInstance`도 얇은 `CollectionService` 래퍼일 뿐이라 +(`bind-system-plan.md` "PA님 코드와 대조" 절) **Instance 태그는 +`CollectionService` 직접 사용 그대로 유지** — 별도 자체 태그 시스템(v1이 +검토했던 것 같은) 안 만듦. + +## 메커니즘 — 새 아키텍처 개념 불필요 + +`isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 `TagHandler` 하나로 +충분: + +- `process(inst, k, v)` — `v`가 참이면(`true`) `CollectionService:AddTag(inst, + name)`, 거짓/`nil`이면 `RemoveTag(inst, name)`. `None → nil` 재디스패치 + (`base/bind-system-plan.md`의 `None` 센티널 절)가 그대로 이 경로를 탐 — + `nil`을 "태그 없음"으로 자연스럽게 해석하면 되므로 특별 처리 불필요. +- **`retract` 불필요** — 값이 `true`/`false`/`nil` 무엇이든 항상 같은 + `TagHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜), 추가/제거를 + 전부 `process` 자신이 처리. `retract`는 "매치되는 핸들러 타입 자체가 + 바뀌는" 경우에만 의미 있다는 게 확정된 원칙(`bind-system-plan.md` + "확정된 디스패치 모델" 절, Tween↔일반 프로퍼티가 그 유일한 실사례) — + Tag는 여기 해당 안 됨. **처음엔 이 문서 없이 "확정된 디스패치 모델" + 절이 Tag를 retract 필요 예시로 잘못 들었던 걸 여기서 바로잡음.** + +## 패키지 배치 + +UICorner 숏핸드/Tween과 같은 판단 재사용 — 작고 항상 켜져 있어도 비용이 +무시할 만한 기능은 `quad-roblox` 코어에 직접 포함(`base/ui-shorthand-plan.md` +"패키지 배치" 절 참고, 별도 opt-out 패키지로 안 쪼갬). + +## 열린 질문 + +없음 — 값 모양/메커니즘/retract 여부 전부 확정. 이름 자체(`Tag`)는 이미 +쓰기 시작한 v1/PA님 관례와 일치해 특별히 재검토 대상 아님. diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 390821b..b394394 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -69,6 +69,26 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 않음. 사용자가 직접 만든 `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 값으로 바인드 가능했음 diff --git a/.claude/question.md b/.claude/question.md index fd416d9..267a593 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -91,6 +91,18 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` "이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭. +- **`None`/`NoneHandler`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: + 인라인 키/Modifier setter로 필드를 명시적으로 지우는 센티널과, 그걸 + `nil`로 바꿔 재디스패치하는 base 내장 핸들러 이름 — + `modifier-plan.md` "2-1"절/`bind-system-plan.md`의 `None` 센티널 + 절에서 동작은 확정, 이름만 다른 가칭들과 같이 재검토 대상. +- **`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"류를 사용자가 검토했으나 미확정. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ @@ -126,6 +138,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **`canExecute`/`Connected`의 실제 구현 방식(Parent==nil vs Connection. Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6. + **(2026-08-07 여덟 번째 세션 보강)** 시그니처는 `(handle) -> boolean`으로 + 확정(zero-arg 클로저 아님, `base/lifecycle-pattern.md` 참고)됐고 + rbvm식 gchold 스케치(weak per-instance 배열에 절대 안 발화하는 + Connection을 넣어 그 클로저 업밸류로 Observer를 살려두는 방식)도 + 후보로 적어뒀지만, 여전히 스케치 단계 — Observer→Connection 역참조를 + weak 릴레이션으로 둘지 평범한 필드로 둘지 포함, 실측은 그대로 필요. - **~~`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 필요로 함(로드맵 순서 역전)~~ — 반영 완료(2026-08-07 세 번째 세션)**: `LifetimeHandle`/`PerInstanceState` 인터페이스(타입만)를 `ROADMAP.md` @@ -137,15 +155,6 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** ### 3. 낮은 우선순위 -- **`None`(가칭) 센티널 프리미티브 — 미확정, 2026-08-07 세 번째 세션 - 신설.** `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 - 명시적으로 지우고 싶어도 Lua 테이블 리터럴의 `키 = nil`은 키가 아예 - 없는 것과 구별이 안 돼서 "인라인이 modifier보다 무조건 우선"이라는 - 기존 merge 규칙(`modifier-plan.md` 2번)이 이 케이스에선 실제로 작동을 - 안 함. `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 실재하는 값 - `None`을 도입하는 방향만 나왔고 상세(타입, flatten 내부 표현, State - 필드에도 같은 문제가 적용되는지)는 미정 — `modifier-plan.md` "2-1"절 - 참고, M7(Modifier) 착수 전 확인. - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 착수를 막지 않음. - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — diff --git a/CLAUDE.md b/CLAUDE.md index 2ea1303..7224758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -991,3 +991,174 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 여덟 번째 세션 — `None` 센티널 확정(인라인 필드 지우기), `NoneHandler`가 Tween store-bind와 같은 재귀 재디스패치임을 확인 + +세 번째 세션에서 "미확정"으로 메모만 남겨뒀던 `None` 센티널 +(`{ Override = nil, mod }`처럼 인라인 키로 modifier 값을 명시적으로 +지우고 싶어도 Lua 테이블의 `키 = nil`이 "키 없음"과 구별 안 되는 문제)을 +사용자가 "이거 결정할 게 진짜 있냐"고 다시 제기해 라이브로 짧게 논의, +확정까지 감. 전부 `base/modifier-plan.md`(2-1번)/`base/bind-system-plan.md` +(신규 절)/`base/ui-shorthand-plan.md`(신규 절)에 반영 완료: + +- **merge/setter 쪽은 아무것도 안 바뀜** — `None`은 raw 저장 계층의 그냥 + 평범한 실재값이라, 기존 merge 규칙("인라인 키 존재 시 무조건 우선", + `Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. + 처음엔 "merge 시점에 키를 지운다"는 새 분기가 필요하다고 잘못 생각했다가, + "값을 표현만 할 수 있으면 기존 규칙이 이미 다 해줌"이라는 걸로 정정. + 인라인 props 테이블 키(`{ TextColor3 = None, mod }`)와 Modifier setter + 인자(`mod:TextColor3(None)`) 둘 다 지원 — 메커니즘이 완전히 같아 구현 + 비용 거의 0("어차피 무료로 얻어지는거 아님?" — 사용자), 후자 덕에 + "특정 필드만 지우는 재사용 가능한 modifier 조각" 패턴도 공짜로 됨. + `:Peek()` 반환 타입도 `T | State | None | nil`로 확장(raw 계층에서 + `None`을 있는 그대로 돌려줌 — Peek은 확정 안 하고 그대로 넘긴다는 기존 + 9번 절 원칙 그대로). +- **실제 "지우기"는 디스패치 단계에서, 새 메커니즘 없이 풀림 — 핵심 + 발견.** 처음엔 "우선순위 최상단에서 값을 그냥 nop 처리"로 생각했다가, + 사용자가 "그럼 process에 특수 로직이 들어간다"고 지적하며 더 나은 안을 + 직접 제시: `NoneHandler`라는 평범한 pluggable 핸들러 하나를 추가 — + `isHandlable`이 `v == None`을 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 + **`process(inst, k, nil)`을 재귀 호출**. 이게 바로 이미 확정돼 있던 Tween + store-bind 핸들러(`bind-system-plan.md` "확정된 디스패치 모델" 절, + `v`가 Store면 `realv`를 계산해 `process(inst,k,realv)`로 재귀)와 + **완전히 같은 패턴**이라는 걸 확인 — 새 아키텍처 개념이 하나도 안 늘어남. + base 드라이버 자체(`process(inst,k,v) -> getHandler(inst,k,v).process(...)`)는 + `None`을 전혀 모르는 순수 제네릭 그대로 유지, 개별 프로퍼티/이벤트/UI + shorthand 핸들러 시그니처도 `None`이 안 나옴(원래 있어야 했던 "`v`가 + `nil`인 경우" 처리를 재사용할 뿐). +- **`None`의 의미는 "리셋"이 아니라 "이 조합 단계에서 이 필드를 세팅 + 안 함"** — 실제로 `v=nil`을 받은 핸들러가 뭘 할지는 핸들러마다 다름(일반 + 프로퍼티는 사실상 그대로 두는 것과 다름없고, UICorner 숏핸드처럼 실제 + Instance를 만들어 붙이는 핸들러는 그 자식을 지움). 구체 사례로 + `ui-shorthand-plan.md`에 UICorner 절 신설 — `process(inst,k,nil)`이 + 만들어둔 `_quad_corner`류 자식을 직접 지움(이건 `retract`가 아니라 + `process` 자신의 로직 — `retract`는 "다른 핸들러가 키를 넘겨받는" 별개 + 시나리오 전용, 이미 확정돼 있던 원칙 재확인), 값이 자주 `nil`↔숫자로 + 토글되면 생성/제거 비용이 매번 든다는 캐비엇도 명시. +- **M2(디스패치 엔진) 착수 시 확인할 것 하나 새로 생김** — "이 키를 지금 + 누가 담당 중인가" bookkeeping이 바깥 순회 루프가 아니라 `process` 호출 + 자체 내부에서 갱신돼야 함. 안 그러면 값이 계속 `None`으로 유지되는 매 + 사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤 실제 담당은 다른 + 핸들러"로 바깥 루프가 오판해 불필요한 `retract`가 반복 호출될 위험 — + `ROADMAP.md` M2에 반영, `pre-implementation-audit.md`의 "이전 매치 + 핸들러 추적" 항목과 같은 부류라 새 우선순위 등급 없이 거기 흡수. + +**부수 정리**: `question.md`에서 "미확정"이던 `None` 항목을 해소로 +제거하고, 이름 자체(`None`/`NoneHandler`)만 다른 가칭들과 같이 용어 +정리 대상(3순위)으로 새로 추가. `ROADMAP.md` M7 체크박스를 "확정 완료"로 +갱신. + +**같은 세션 후속 — `Dispatch` 함수 네이밍 정리, `canExecute` 시그니처 정정, +Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 엔진 자체의 +용어가 여러 군데서 흔들리고 있다는 게 드러나 바로 이어서 정리함. 전부 +`base/bind-system-plan.md`/`base/lifecycle-pattern.md`/`base/tag-plan.md` +(신규)/`base/attribute-plan.md`(신규)/`ROADMAP.md`에 반영 완료: + +- **제 실수 정정 — `canExecute`와 `isHandlable`은 다른 개념.** + `isHandlable(k,v)`는 KV 매치 predicate(핸들러 계약 4종 중 하나), + `canExecute`는 특정 바인딩 하나가 "지금 살아있어 실행돼도 되는가"만 + 보는 별개의 라이프타임 게이트(`lifecycle-pattern.md`) — `NoneHandler`가 + 구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님, 앞서 잘못 쓴 + 문장을 고침. +- **`Dispatch.getHandler`/`Dispatch.process`/`Dispatch.addHandler`/ + `Dispatch.drive`로 이름 공식화.** 원래 "확정된 디스패치 모델" 절은 + "스캔+실행"과 "매치된 핸들러 자신의 처리"를 둘 다 그냥 `process`라고 + 불러 이름이 겹쳤던 게 혼동의 원인이었음 — `Dispatch.getHandler(inst,k,v): + Handler?`(순수 스캔)와 `Dispatch.process(inst,k,v)`(오케스트레이터: + getHandler → 이전 담당자 다르면 그 `retract` → 새 핸들러의 `.process`)로 + 분리, Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, + 재검토 대상 아님) — 겹침은 소유자 표기(`Dispatch.process` vs + `handler.process`)로 해소, 새 이름 발명 안 함. **`Dispatch.addHandler(handler)`** + 도 신설 — concrete Handler를 우선순위 레지스트리에 등록하는 것도 + 결국 quad-roblox가 `BaseModule`을 뮤테이션하는 시점에 해줘야 하는 + 일이라(기존 "base 유틸은 인터페이스, 백엔드가 주입" 패턴과 같은 모양). + **배열→해시 두 패스 순회 드라이버 자신은 `Dispatch.drive(inst, + flattened)`로 확정** — 문서가 이미 이걸 비공식적으로 "base 디스패치 + 드라이버"라 불러왔던 걸 그대로 동사화(`apply`는 기각 — "Dispatch를 + 뮤테이션해서 결과를 낸다"는 어감이라 안 맞는다는 사용자 판단). +- **`canExecute` 시그니처 정정: `(handle) -> boolean`, zero-arg 아님.** + `lifecycle-pattern.md`가 원래 `canExecute: () -> boolean`(바인딩마다 + 클로즈오버된 람다)으로 적어뒀던 걸 정정 — 그러면 등록마다 클로저를 + 새로 만들어야 해서 "base는 인터페이스만, quad-roblox가 `BaseModule` + 뮤테이션으로 실 구현 주입"이라는 이미 확정된 패턴과 안 맞음. 공유 + 함수 하나가 되려면 "어떤 등록을 볼지" 가리키는 인자가 필요 — + `canExecute(handle: LifetimeHandle): boolean`으로 확정. **quad-roblox + 구현 스케치(참고용, base 결정 아님)**: rbvm 패턴 재사용 — Instance당 + weak-keyed per-instance 저장소에 "gchold" 배열을 두고, 절대 발화 안 + 하는 신호에 연결한 Connection의 콜백 클로저 안에 살려두고 싶은 + Observer를 업밸류로 캡쳐(콜백은 안 불려도 클로저의 업밸류는 안 죽음, + `inst`가 GC되면 gchold 배열째로 같이 죽음). `canExecute(handle)`은 이 + Connection(류)의 `.Connected`를 확인. **미확인 세부사항으로 남긴 것**: + Observer→Connection 역참조를 별도 weak 릴레이션으로 둘지 그냥 Observer + 테이블 안 평범한 필드로 넣을지(정적 해싱 필드 접근이 더 쌀 수 있음) — + quad-roblox 구현 단계에서 실측 필요. +- **`Tag`/`Attribute`도 UICorner/Tween처럼 전용 문서가 있어야 한다는 + 지적 — 맞아서 `base/tag-plan.md`/`base/attribute-plan.md` 신설.** + 둘 다 이미 `architecture.md`/`ROADMAP.md` M10에 파일로는 계획돼 + 있었지만 "1 프리미티브 1 파일" 관례(Blocker/Effect/Ref/PreRef 분리 + 선례)를 따르는 전용 설계 문서가 없었음 — 흩어져 있던 내용(Attribute의 + 타입 파라미터화 논의 등)을 모으고, 오늘 확정된 `None`/`process`/ + `retract` 동작을 반영. **핵심 발견**: Tag/Attribute 둘 다 UICorner + 숏핸드와 같은 패턴(값이 뭐든 항상 같은 핸들러가 계속 담당, 추가/제거를 + `process` 자신이 처리)이라 **retract가 필요 없음** — "확정된 디스패치 + 모델" 절이 원래 Tag/Attribute를 retract 필요 예시로 들었던 게 잘못이었음, + 바로잡고 "retract가 의미 있는 유일한 패턴은 매치되는 핸들러 *타입* + 자체가 사이클마다 바뀌는 경우(Tween↔일반 프로퍼티가 실사례)"로 좁힘. + Attribute는 특히 깔끔한 사례 — Roblox `SetAttribute(name, nil)` 자체가 + 네이티브하게 "지움"이라 `None→nil` 재디스패치가 특별 처리 없이 그대로 + 맞아떨어짐. +- `.claude/README.md`에 두 신규 문서 반영, `ROADMAP.md` M2/M10 체크박스 + 갱신(`Dispatch` 4개 함수, `canExecute` 시그니처, Tag/Attribute 문서 + 참조). + +**같은 세션 세 번째 후속 — `canExecute` 옵션 하나 더 검토 후 확정 유지, +`Brand` 통합 판별 메커니즘 신설(`isState`를 10종으로 일반화), `isHandlable`도 +`inst`를 받도록 정정.** 전부 `base/bind-system-plan.md`(`Brand` 절, 핸들러 +계약 절)/`base/modifier-plan.md`/`ROADMAP.md`/`question.md`에 반영 완료: + +- **`canExecute`를 "각 핸들 타입이 직접 구현"(`Observer.canExecute`)할지 + "공유 함수"(`canExecute(any)->boolean`)로 할지 재확인 — 공유 함수 유지, + 솔직한 이유까지 명시.** `Observer` 자체는 quad-base 레벨(엔진 무관) + 타입인데 liveness 체크(Connection 기반)는 본질적으로 엔진 종속적이라, + `Observer.canExecute`가 직접 구현하면 base/roblox 분리 원칙이 깨지거나 + 결국 내부적으로 공유 함수를 다시 호출하는 얇은 래퍼가 될 뿐 — 어느 + 쪽이든 공유 함수 쪽이 낫다는 결론 재확인(추가 논의 없이 유지). +- **`Brand` 신설 — `isState`(다섯 번째 세션)를 quad의 다른 branded 타입 + 전부(`Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ + `Source`/`Slot`)로 일반화.** 공유 weak-key 레지스트리 하나(`Brand.set`/ + `Brand.get`) + **문자열이 아니라 테이블 아이덴티티를 태그로 사용** + (사용자 제안 — Luau 인터닝 문자열도 이미 O(1) 포인터 비교라 성능 차는 + 없지만, 오타 안전성이 실질 이득: 잘못된 변수 참조는 즉시 드러나지만 + 오타난 문자열 리터럴은 조용히 어긋남). `isX`는 `Brand`를 감싼 얇은 + wrapper — 단순 항등(`isObserver`)과 집합 멤버십이 필요한 경우(`isState` + = `{State,Source}`)로 갈림. **`None`만 예외 — 싱글턴이라 레지스트리 + 없이 `x == None` 항등 비교가 더 싸고 정확**, 대신 `Brand.get`이 범용 + introspection 창구(quad-debug 용도) 역할까지 겸하도록 `None`을 특수 + 분기로 앞단에서 걸러줌 — `isNone`이 그 분기의 실제 구현체. +- **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 + 뒤집음.** 그땐 "State면 충분한 용도"만 봤지만 `Source`는 State보다 + 진짜 더 많은 능력(`:Set`/`:Emit`)을 가진 서브타입이라 "쓰기도 되는 + 원천인가"를 알아야 하는 코드엔 `isState`만으론 부족 — `isSource` 별도 + 제공, `isState`는 여전히 `{State,Source}` 둘 다 통과. `component- + composition-plan.md` 4번 절이 애초에 `isSource`가 존재한다고 가정해둔 + 것과도 이걸로 정합됨(그동안 두 문서가 서로 모순돼 있었음, 이번에 발견). +- **Luau 타입 narrowing은 자동으로 안 됨 — 사용자가 직접 확인, 명시적 + `::` 캐스팅 필요.** `isX(v)`가 참이어도 Luau가 TypeScript의 `x is T` + 같은 사용자 정의 타입 가드를 지원 안 해서 `v`의 정적 타입을 자동으로 + 안 좁혀줌 — `if isState(v) then local s = v :: State ... end`처럼 + 런타임 검증 뒤 명시적 캐스팅이 실제 패턴. 여전히 duck-typing보다 훨씬 + 안전하니 가치는 있지만 자동 narrowing을 기대하면 안 됨. +- **`isHandlable`도 `inst`를 받도록 확정 — `(inst,key,value): boolean`, + 원래 `(key,value)`였던 걸 정정.** `process`/`retract`는 처음부터 + `inst`를 항상 받았는데(핸들러 계약 원 원칙) `isHandlable`만 예외였던 + 게 애초에 약간의 불일치 — 지금 당장 `inst`로 매치가 갈리는 케이스는 + 없지만, 나중에 필요해지면 핸들러 계약 자체를 깨는 breaking change가 + 되므로 지금 넣어두는 게 훨씬 쌈. `Dispatch.getHandler`가 스캔 중 + `handler.isHandlable(inst,k,v)`로 호출하도록 갱신. +- `ROADMAP.md` M2에 `Brand.luau` 체크박스 신설, `Handler.luau`/M7의 + `isState` 항목 갱신. `question.md`에 `Brand` 이름(용어 정리 대상, + "OOP 클래스명을 얻는 느낌"에 맞는 더 나은 이름 필요 — `Tag`는 이미 + quad-roblox에서 다른 뜻으로 쓰여서 충돌) 반영. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index 216543f..5921179 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,14 +50,38 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M2 — 디스패치 엔진 -- [ ] `Dispatch/init.luau`(`process`/`retract` 엔진, `isHandlable` 우선순위 스캔) -- [ ] `Handler.luau`(핸들러 계약 타입) +- [ ] `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 시에도 자연히 갱신되게 — 안 그러면 재귀 + 재디스패치를 쓰는 케이스(Tween store-bind, `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` + 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등 + 비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째 + 세션 신설) - [ ] `LifetimeHandle.luau`/`PerInstanceState.luau` **인터페이스만**(타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` 2번 - — 2026-08-07 세 번째 세션에 반영) + — 2026-08-07 세 번째 세션에 반영). **`canExecute`는 `(handle: + LifetimeHandle) -> boolean`으로 확정**(바인딩마다 클로저 만드는 + zero-arg가 아니라, quad-roblox가 한 번만 주입하는 공유 함수 — "base + 유틸은 인터페이스, 백엔드가 주입" 패턴과 맞춰야 해서. + `base/lifecycle-pattern.md`의 gchold 스케치 절, 2026-08-07 여덟 번째 + 세션 정정) - [ ] mock 대상 테스트 ## M3 — Store/State/Source @@ -119,12 +143,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) - [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 + - `isState(x): boolean`(weak-key 레지스트리 기반, quad-base 공용 - 유틸 — `modifier-plan.md` 9번, `bind-system-plan.md`의 `isState` 절) -- [ ] 인라인 키로 modifier 필드를 명시적으로 지우는 문제 확인 — `None` - (가칭) 센티널 프리미티브 도입 여부(`modifier-plan.md` 2-1번, 아직 - 미정 — 착수 전 사용자 확인 필요, 확정 안 되면 이번 마일스톤은 - 스킵하고 다음으로 미뤄도 됨) + `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 엔진의 + "이전 매치 핸들러 추적" 항목과 함께 구현 — Tween store-bind 핸들러와 + 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — 확정 완료 ## M8 — Ref @@ -153,8 +180,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M10 — Event / Attribute / Tag - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) -- [ ] `Handlers/Attribute.luau` -- [ ] `Handlers/Tag.luau`(`CollectionService`) +- [ ] `Handlers/Attribute.luau`(`base/attribute-plan.md` — 메커니즘/`None`/ + `retract` 불필요 확정, 타입 파라미터화 이름만 착수 전 확인) +- [ ] `Handlers/Tag.luau`(`CollectionService`, `base/tag-plan.md` — 전부 확정) ## M11 — Tween From 98bd46af0932d1980681163090444e108387387b Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 19:34:40 +0900 Subject: [PATCH 41/62] =?UTF-8?q?docs:=20=EC=BD=94=ED=8D=BC=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EB=B0=98=EC=98=81,=20agent-mistake.md=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 여러 세션에 걸쳐 쌓인 stale 참조/자기모순을 서브에이전트 병렬 감사로 찾아내 전부 수정: - bind-system-plan.md: CreatedRef {phase=...} 옵션이 폐기 이후에도 두 곳에 방치돼 있던 것을 archive 포인터로 정리 - question.md: Ref 이름 재검토 대상 여부 자기모순 해소, framework- comparison-findings.md/v1-compat-plan.md §8 누락 항목 보강 - UICorner 숏핸드 개명(구 Modifier.Rounded(8))을 modifier-plan.md/ store-semantics.md/ui-shorthand-plan.md/documentation-content-map.md/ pre-implementation-audit.md 5곳에 전파 - canExecute(handle) 시그니처 정정을 bind-system-plan.md/ store-semantics.md 예시 호출부에 전파 - architecture.md/ROADMAP.md/CLAUDE.md의 stale 문구·누락 참조 정정 - store-semantics.md 제목을 "State는 Source 위의 캐시 레이어"로 정정 (Store 아님 — 사용자 확인) archive/agent-mistake.md 신설 — 설계 반전/기각과 구분되는 세 번째 카테고리로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 같은 세션 안에서 정정한 사례(canExecute/isHandlable 혼동, isSource 오판) 전용. CLAUDE.md 세션 로그의 중복 서술을 옮기고 포인터만 남김. slot-plan.md의 CRUD 의미론 갭은 사용자 요청으로 이번 라운드에서 보류. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 3 +- .claude/archive/agent-mistake.md | 39 +++++++++++++++++++ .claude/base/architecture.md | 4 +- .claude/base/bind-system-plan.md | 17 ++++---- .claude/base/modifier-plan.md | 6 +-- .claude/base/store-semantics.md | 6 +-- .claude/base/ui-shorthand-plan.md | 2 +- .claude/question.md | 14 ++++++- .claude/research/documentation-content-map.md | 2 +- .claude/research/pre-implementation-audit.md | 20 ++++++---- .claude/research/tween-plan.md | 2 +- CLAUDE.md | 26 +++++-------- ROADMAP.md | 10 +++-- 13 files changed, 104 insertions(+), 47 deletions(-) create mode 100644 .claude/archive/agent-mistake.md diff --git a/.claude/README.md b/.claude/README.md index a4cb785..30dd6d5 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -13,7 +13,7 @@ | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | -| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것 | +| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | | `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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -70,6 +70,7 @@ | `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 전달로 충분하다는 판단 | +| `agent-mistake.md` | **[에이전트 실수, 2026-08-07 신설]** 설계 반전이 아니라 에이전트가 문서 작성 중 개념을 혼동했다가 같은 세션 안에서 스스로 정정한 사례 모음(`canExecute`/`isHandlable` 혼동, `isSource` 불필요 오판) — CLAUDE.md 세션 로그의 중복 서술을 여기로 옮기고 포인터만 남김 | ## 참고 diff --git a/.claude/archive/agent-mistake.md b/.claude/archive/agent-mistake.md new file mode 100644 index 0000000..735299d --- /dev/null +++ b/.claude/archive/agent-mistake.md @@ -0,0 +1,39 @@ +# 에이전트 실수 기록 + +CLAUDE.md 세션 로그 안에 흩어져 있던 "에이전트가 같은 세션 안에서 스스로 +정정한 실수" 서술을 여기로 모음 — 최종 결론은 이미 각 `base/` 문서에 +정확히 반영돼 있어서 CLAUDE.md에 전체 문단을 남겨둘 필요는 없지만(중복), +같은 실수를 반복하지 않기 위한 기록 자체는 남겨둘 가치가 있음. 다른 archive +문서(`*-reversed.md`/`*-rejected.md`)와 달리 이건 "설계 결정의 반전"이 +아니라 "에이전트가 문서를 쓰다가 실제로 개념을 혼동했던 사례" 전용. + +## 1. `canExecute`와 `isHandlable`을 같은 개념으로 혼동 (2026-08-07 여덟 번째 세션) + +**실수**: `NoneHandler`(값을 `None`에서 `nil`로 바꿔 재디스패치하는 base +내장 핸들러)를 설계하며 그 매치 조건을 `canExecute`로 잘못 서술함. + +**정정**: 둘은 완전히 다른 계층 — `isHandlable(k,v)`는 KV 매치 +predicate(핸들러가 이 키/값을 담당하는지 판단, 핸들러 계약 4종 중 하나), +`canExecute(handle)`는 특정 바인딩 하나가 "지금 살아있어 실행돼도 +되는가"만 보는 별개의 라이프타임 게이트(`base/lifecycle-pattern.md`). +`NoneHandler`가 구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님. + +**현재 유효한 설계**: `base/bind-system-plan.md`의 `None` 센티널 절과 +"매치 predicate는 `isHandlable`" 절이 최종 소스. + +## 2. `isSource`가 불필요하다고 잘못 판단 (다섯 번째 세션 → 여덟 번째 세션에서 정정) + +**실수**: 2026-08-07 다섯 번째 세션에서 `isState`/`isSource` predicate를 +설계하며 "State면 충분한 용도만 있으니 `isSource`는 따로 안 만들어도 +된다"고 서술. 이때 `base/component-composition-plan.md` 4번 절은 이미 +`isSource`가 존재한다고 가정하고 쓰여 있었는데, 그 모순을 그때는 못 +찾아냄. + +**정정**: `Source`는 `State`보다 실제로 더 많은 능력(`:Set`/`:Emit`)을 +가진 서브타입이라, "쓰기도 되는 원천인가"를 알아야 하는 코드는 +`isState`만으론 부족함 — `isSource`를 별도로 제공해야 함. `isState`는 +여전히 `{State, Source}` 둘 다 통과시킴(상위집합 판별 유지). + +**현재 유효한 설계**: `base/bind-system-plan.md`의 `Brand` 절 +(`isState`/`isSource`가 둘 다 존재, 전자는 집합 멤버십, 후자는 단순 항등)이 +최종 소스. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index b7d2471..fdfbad8 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -159,8 +159,8 @@ quad/ **남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된 `slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서. -Tween/purity/existing-instance-bind는 여전히 `research/`에 남아있고 이 -구조 확정을 막지 않음. +Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을 +막지 않음(`purity-and-effects-plan.md`는 이미 `base/`로 승격 완료). ## 테스트 전략: quad-base용 최소 mock (2026-08-04) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index a3594b4..5808152 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -398,9 +398,11 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는 트레이드오프 아님. - **`CreatedRef`와의 관계**: 둘은 상충하지 않음 — 이 절의 Ref가 범용 - 프리미티브, `CreatedRef(fn, {phase=...})`는 그 위에 얹힌 "children - 배열에 넣으면 dispatch가 자동으로 채워주는" 특수 편의 패턴(quad가 - 만든 instance에 한정된 경우). + 프리미티브, `CreatedRef(fn)`는 그 위에 얹힌 "children 배열에 넣으면 + dispatch가 자동으로 채워주는" 특수 편의 패턴(quad가 만든 instance에 + 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 기반 + `PreRef` + 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" + 절이 최신. - **해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정.** React에서도 자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스 호버/무브 시 `current` 확인 후 라벨 위치를 결정하는 라벨 컨테이너 @@ -1132,7 +1134,7 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사 - `base/store-semantics.md`에 있던 "`isInit=false`면 허용, `isInit=true`+ 생존확인 거짓이면 불허" 분기 초안은 폐기. state-invalidate 리스너 클로저도 `base/lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute - predicate)로 등록하면, 발화 시 `canExecute()` 하나만 확인하고 거짓이면 + predicate)로 등록하면, 발화 시 `canExecute(handle)` 하나만 확인하고 거짓이면 그냥 no-op — `isInit` 분기라는 별도 개념 자체가 불필요(사용자 확정: "canExecute 하나로 통일"). @@ -1467,9 +1469,10 @@ State ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 모듈 이름** — 방향은 전부 확정, 이름만 구현 단계에서 남음(`On` 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고). -- **`CreatedRef`(가칭)의 정확한 함수/옵션 이름** — children 배열에 아이템으로 - 넣는다는 방향과 생성/마운트 두 시점 모두 지원한다는 것은 확정, 정확한 API - 이름만 남음. +- **`CreatedRef`(가칭)의 정확한 함수 이름** — children 배열에 아이템으로 + 넣는다는 방향, 그리고 타이밍은 옵션 값이 아니라 위치 + `PreRef` 타입으로 + 표현한다는 것까지 확정(위 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` + 신설" 절), 정확한 API 이름만 남음. - **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상). diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 03ddfba..1a2a7df 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -208,14 +208,14 @@ Modifier에는 없음). "누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에 이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트 전역 관습(`base/bind-system-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 -됨 — `Modifier.Rounded(8)`/`Modifier.FontSize(...)`처럼 DI 쪽 "제네릭 -생성자 함수 하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. +됨 — `mod:UICorner(8)`/`mod:FontSize(...)`처럼 DI 쪽 "제네릭 생성자 함수 +하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. (주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/bind-system-plan.md` "이벤트 바인딩 정정" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.) -`Modifier.Rounded(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 +`mod:UICorner(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 `Corner` 특수 프로퍼티 선례, 핸들러 배치 소견)는 `base/ui-shorthand-plan.md` 참고 — 이 문서는 Modifier 값 자체의 동작만 다루므로 분리. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 392ef06..9d41fa0 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -1,4 +1,4 @@ -# Store 의미론 — 부작용 허용, State는 Store 위의 조합 가능한 캐시 레이어 +# Store 의미론 — 부작용 허용, State는 Source 위의 조합 가능한 캐시 레이어 **상태**: base — 전부 확정. State/Source 온톨로지는 2026-08-04 검증 라운드에서 새로 열려 같은 세션 2~4차 라운드에 걸쳐 확정까지 마침 — 최신 @@ -31,7 +31,7 @@ purity-and-effects-plan.md`와 연결됨). 어떻게 되는가 — 별도 메커니즘을 새로 만들 필요 없이, `base/ lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute predicate)을 state- invalidate 리스너 클로저 등록에도 그대로 재사용하면 됨: 발화 시 -`canExecute()` 하나만 확인, 거짓이면 no-op. 한때 검토했던 `isInit=false`면 +`canExecute(handle)` 하나만 확인, 거짓이면 no-op. 한때 검토했던 `isInit=false`면 허용/`isInit=true`+생존확인 거짓이면 불허 분기 초안은 폐기 — `canExecute` 하나로 통일(사용자 확정). 상세는 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. @@ -73,7 +73,7 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 - **독립 존재 가능한 프리미티브** — Source, Ref, Store, Modifier. 다른 무언가 없이 그 자체로 `Type(args)` 팩토리 함수로 만들어짐(`Source(default)`/ - `Ref(default)`/`Store({defaults})`/`Modifier.Rounded(8)`, 위 "생성자 + `Ref(default)`/`Store({defaults})`/`mod:UICorner(8)`, 위 "생성자 스타일 확정" 참고). - **원천에 종속된 파생 데이터** — State, Observer. 자기 혼자 존재할 수 없고 항상 특정 원천(Source/다른 State)에 의존 — 그래서 이 둘은 자유 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index b394394..cc71210 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -56,7 +56,7 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ "이름 붙은 자식을 찾거나 만들고 프로퍼티 세팅"을 `process(inst, k, v)`에 구현 — v1의 하드코딩 if/elseif 대신 정식 핸들러 계약(`isHandlable`/ `priority`/`process`/`retract`)을 따르는 것만 다름. `modifier-plan.md`가 -이미 예시로 든 `Modifier.Rounded(8)`은 이 특수 키를 flatten해서 props에 +이미 예시로 든 `mod:UICorner(8)`은 이 특수 키를 flatten해서 props에 꽂아넣는 사탕 문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고 `Frame { UICorner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게 작동함, `architecture.md`의 `[Attribute "Name"]`류 특수 키와 같은 층위. diff --git a/.claude/question.md b/.claude/question.md index 267a593..2b9d6f1 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -111,8 +111,9 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **이미 지나간 사례로 참고**: `register`(v1) → `State`(v2) 리네임은 "모호함"은 풀었지만 "다른 뜻으로 이미 쓰이는 단어"라는 새 문제를 만든 셈 — 이번 정리에서 같은 패턴을 조심할 것. -- `Store`/`Source`/`Modifier`/`Ref`/`process`/`retract`/`isHandlable`은 - 업계 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. +- `Store`/`Source`/`Modifier`/`process`/`retract`/`isHandlable`은 업계 + 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음 + (`Ref`는 여기 포함 안 됨 — 위 3순위 목록에 이미 재검토 대상으로 있음). ### 2. 구현 착수 직전 감사 결과 (2026-08-06 신설, M0 착수 전 확인 권장) @@ -195,6 +196,15 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 모두 기술적 근거와 안전 규칙까지 정리됐으나(문서 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 스파이크 때 같이 검증할지 나중 최적화 + 패스로 미룰지 — 아직 사용자 판단 전. ## 참고: 지금까지 확정된 것 (요약) diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index f62f006..44b6109 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -86,7 +86,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 ### modifier-plan.md / slot-plan.md - 초심자: Modifier 기본 체이닝+merge 우선순위 규칙 실제 예시 / Slot 기본 개념(children 배열)+클래스가 슬롯 받는 방법(Named Slot 없음) / 마운트된 slot 재마운트 시 즉시 throw -- api: Setter가 리터럴/변환 함수 둘 다 받음(→심화: getter 없는 이유) / 필드가 State일 수 있는 4가지 조합 표(→심화: 반응성 유지/끊김 이유) / `Modifier.Rounded(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) / `:Apply(factory)` 기본 체이닝 관용구(→심화: 언제 `Apply` vs `Override`인지 성능 기준) / `:Peek<>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유) +- api: Setter가 리터럴/변환 함수 둘 다 받음(→심화: getter 없는 이유) / 필드가 State일 수 있는 4가지 조합 표(→심화: 반응성 유지/끊김 이유) / `mod:UICorner(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) / `:Apply(factory)` 기본 체이닝 관용구(→심화: 언제 `Apply` vs `Override`인지 성능 기준) / `:Peek<>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유) - 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) / **왜 `Apply`가 기본이고 `Override`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선) - 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정 - skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 5d546a2..fd7dcc8 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -210,7 +210,12 @@ element별 weak-set) — throw 조건을 "Slot 핸들러의 `process`가 같은 ### 1-9. `LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 필요로 함(로드맵 순서 역전) -**위치**: `ROADMAP.md` M8 "Ref" — `"LifetimeHandle 인터페이스 + quad-roblox +**[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 @@ -403,7 +408,7 @@ Modifier를 합친다"는 시나리오가 `Override`의 가장 그럴듯한 실 관습 재사용" 절, `.claude/question.md` 1번. **문제**: Modifier의 런타임 체이닝 엔진은 quad-base 소유가 맞지만, 클래스별 -정적 타입 안전성(`Modifier.Rounded(8)`가 `FrameModifier` 타입으로 추론되는 +정적 타입 안전성(`mod:UICorner(8)`가 `FrameModifier` 타입으로 추론되는 것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴 재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게 결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch, @@ -561,12 +566,13 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 ## 다음 액션 제안 -- **M0 착수 전**: 1-5(props.Modifier/Ref nil-hole)를 M0 스파이크 코드에 - 반영, 1-10(`store.key` 레코드 필드 타이핑)을 M0로 앞당기는 것 검토, - 1-11(Modifier `__index` 트릭)도 비용이 낮으니 M0 후보로 포함 검토. +- **M0 착수 전**: 1-5(props.Modifier/Ref nil-hole)는 `ROADMAP.md` M0에 + 반영 완료. 1-10(`store.key` 레코드 필드 타이핑)을 M0로 앞당기는 것 검토, + 1-11(Modifier `__index` 트릭)도 비용이 낮으니 M0 후보로 포함 검토는 계속 + 열려있음. - **M2(Dispatch) 착수 전**: 1-2, 1-3, 1-4를 한 번에 확정(전부 base dispatch 엔진의 에러/상태관리 규칙이라 같이 결정하는 게 효율적). -- **M2/M3 착수 전**: 1-6(canExecute 실제 구현) 실측, 1-9(LifetimeHandle - 마일스톤 재배치). +- **M2/M3 착수 전**: 1-6(canExecute 실제 구현) 실측(1-9는 반영 완료, 위 + 항목 참고). - **나머지**: 해당 마일스톤 착수 시점에 이 문서를 다시 열어 관련 항목만 확인하면 됨 — 지금 전부 결정할 필요는 없음. diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 1b949a0..c1fb808 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -1,4 +1,4 @@ -# Tween / 애니메이션 플러깅 (기본값 확정, 옵션 키 이름만 남음) +# Tween / 애니메이션 플러깅 (기본값 확정, 옵션 키 이름·값 모양만 남음) **상태**: research — 방향은 뚜렷하게 잡혀 있고(라이브러리가 트윈을 직접 구현하지 않는다), `retract` 순서/오버라이드 기본값(Cancel)도 확정됨. 남은 건 diff --git a/CLAUDE.md b/CLAUDE.md index 7224758..78f41d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,11 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `tween-plan.md`/`existing-instance-bind-plan.md`/`debug-tooling-plan.md`/ `documentation-plan.md`/`documentation-content-map.md`/ `framework-comparison-findings.md`/`additional-primitives-plan.md`(키 기반 - 동적 컬렉션 재조정만 남음) — 전부 후순위(급한 건 `tween-plan.md` - 세부 옵션 정도). 최신 목록·우선순위는 `.claude/README.md`가 소스, 여기서 - 개수 반복 안 함(과거에 "두 개뿐"이라 적어놨다가 새 문서 추가될 때마다 - 안 갱신되는 패턴이 반복돼서 아예 안 세기로 함). + 동적 컬렉션 재조정만 남음)/`pre-implementation-audit.md`/`v1-compat-plan.md` + — 전부 후순위(급한 건 `tween-plan.md` 세부 옵션 정도). 최신 목록·우선순위는 + `.claude/README.md`가 소스, 여기서 개수 반복 안 함(과거에 "두 개뿐"이라 + 적어놨다가 새 문서 추가될 때마다 안 갱신되는 패턴이 반복돼서 아예 안 + 세기로 함). - `.claude/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, 지금은 비어있음. `.claude/archive/`는 원래 같은 취급이었으나 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 @@ -1054,12 +1055,9 @@ Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 `base/bind-system-plan.md`/`base/lifecycle-pattern.md`/`base/tag-plan.md` (신규)/`base/attribute-plan.md`(신규)/`ROADMAP.md`에 반영 완료: -- **제 실수 정정 — `canExecute`와 `isHandlable`은 다른 개념.** - `isHandlable(k,v)`는 KV 매치 predicate(핸들러 계약 4종 중 하나), - `canExecute`는 특정 바인딩 하나가 "지금 살아있어 실행돼도 되는가"만 - 보는 별개의 라이프타임 게이트(`lifecycle-pattern.md`) — `NoneHandler`가 - 구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님, 앞서 잘못 쓴 - 문장을 고침. +- **제 실수 정정 — `canExecute`와 `isHandlable`은 다른 개념** (전체 경위는 + `archive/agent-mistake.md` 1번으로 옮김) — 결론만: `NoneHandler`가 + 구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님. - **`Dispatch.getHandler`/`Dispatch.process`/`Dispatch.addHandler`/ `Dispatch.drive`로 이름 공식화.** 원래 "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리"를 둘 다 그냥 `process`라고 @@ -1136,12 +1134,8 @@ Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 introspection 창구(quad-debug 용도) 역할까지 겸하도록 `None`을 특수 분기로 앞단에서 걸러줌 — `isNone`이 그 분기의 실제 구현체. - **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 - 뒤집음.** 그땐 "State면 충분한 용도"만 봤지만 `Source`는 State보다 - 진짜 더 많은 능력(`:Set`/`:Emit`)을 가진 서브타입이라 "쓰기도 되는 - 원천인가"를 알아야 하는 코드엔 `isState`만으론 부족 — `isSource` 별도 - 제공, `isState`는 여전히 `{State,Source}` 둘 다 통과. `component- - composition-plan.md` 4번 절이 애초에 `isSource`가 존재한다고 가정해둔 - 것과도 이걸로 정합됨(그동안 두 문서가 서로 모순돼 있었음, 이번에 발견). + 뒤집음** (전체 경위는 `archive/agent-mistake.md` 2번으로 옮김) — 결론만: + `isSource`를 별도 제공, `isState`는 여전히 `{State,Source}` 둘 다 통과. - **Luau 타입 narrowing은 자동으로 안 됨 — 사용자가 직접 확인, 명시적 `::` 캐스팅 필요.** `isX(v)`가 참이어도 Luau가 TypeScript의 `x is T` 같은 사용자 정의 타입 가드를 지원 안 해서 `v`의 정적 타입을 자동으로 diff --git a/ROADMAP.md b/ROADMAP.md index 5921179..7ad5505 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,8 +4,9 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 `.claude/base/`가 소스, 여긴 **순서와 진행 상황**만. 마일스톤 시작할 때 체크박스를 세분화해서 늘려도 되고, 끝나면 체크만 하면 됨 — 살아있는 문서. -**2026-08-04 세션에 준비만 해둔 상태 — 아직 M0도 시작 안 함.** 다음 세션은 -바로 M0부터. +**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 +확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0 +자체는 시작 안 함.** 다음 세션은 바로 M0부터. ## M0 — 스켈레톤 + 기술검증 (스파이크, "진짜" 마일스톤 아님) @@ -32,7 +33,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 폐기 → 위치로 표현, `PreRef` 신설" 절) - [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, `export type Params = {...}`로 타입 체크되는지 확인 - (`component-composition-plan.md` 최종 결론 1번) + (`component-composition-plan.md` 최종 결론 1번) — **caller가 Modifier/Ref를 + 안 넘기는 케이스(Lua 배열 리터럴의 nil-hole 함정, `{nil, ref, child}`처럼 + 뒤 항목이 무시될 수 있는 경우)를 반드시 케이스에 포함** + (`research/pre-implementation-audit.md` 1-5) - [ ] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서 `.claude/base/` 갱신 From 33790df6eed268574e794e105ed8ea32e37a486c Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 20:29:36 +0900 Subject: [PATCH 42/62] =?UTF-8?q?decide(base):=20CreatedRef=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0,=20PreRef=20pre-pass=20=ED=99=95=EC=A0=95,=20None=20?= =?UTF-8?q?=EC=86=8C=EC=A7=84=20=EC=A0=95=EC=A0=95,=20nil-hole=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EA=B4=80=EC=9A=A9=EA=B5=AC=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreatedRef 이름 완전 폐기 — Ref(default)/PreRef(default)가 이미 Compose식 Type(args) 팩토리 생성자로 확정돼 있어 별도 래퍼가 불필요했음(2026-08-04 Ref 일반화 이전 시절의 잔재). bind-system-plan.md/ROADMAP.md/question.md/ architecture.md/documentation-content-map.md 전체 동기화. PreRef pre-pass 구현 위치 확정 — 새 Dispatch.* 함수나 flatten에 얹지 않고 이미 확정된 Dispatch.drive(inst, flattened) 자신이 두 번 순회(pre-pass + 정상 두 패스)하는 것으로 확정. flatten에 얹는 안은 재바인드 시 flatten 재호출 가능성과 충돌해 기각. 복수 PreRef는 배열 index 순서, 동적 경로로 도착한 PreRef는 전용 Handler가 즉시 error. 소진 슬롯을 nil이 아니라 None으로 — 사용자가 Luau REPL 반례로 직접 발견: 키가 촘촘한 저범위 정수에서 벗어나면(nil 구멍 포함) 순회 순서가 index 오름차순을 안 지킴, table.insert가 쓰는 #t도 구멍 있는 테이블에서 정의 안 됨. Ref 콜백/대기자 배열과 PreRef pre-pass 둘 다 None 소진으로 정정. 배열 파트 None(순수 스킵)과 해시 파트 None(NoneHandler 경유)이 다른 경로임을 명시. props.Modifier/props.Ref forwarding에 `or None` 필수 관용구 확정 — nil-hole 위험도가 국소적이지 않고 테이블 전체에 영향을 준다는 게 이번 실측으로 드러나 방어 필요, 기존 None 스킵 메커니즘 재사용이라 새 코드 불필요. 부수 발견/보강: Modifier() 바닥 생성자가 문서에 없던 갭 보강, Brand 태그 목록에 RefTag/PreRefTag/ModifierTag가 빠져있던 갭 보강(isRef/isPreRef는 isState와 달리 단순 항등 — PreRef가 일반 Ref 핸들러에 안 잡히려면 필수). archive/agent-mistake.md 관례에 따라 이번 세션의 실수(Modifier.Rounded(8) stale 치환 시 잘못된 예시로 대체)는 CLAUDE.md 세션 로그에 경위 그대로 남김. Co-Authored-By: Claude Sonnet 5 --- .claude/base/architecture.md | 2 +- .claude/base/bind-system-plan.md | 228 ++++++++++++++---- .claude/base/component-composition-plan.md | 27 ++- .claude/base/modifier-plan.md | 18 ++ .claude/base/store-semantics.md | 5 +- .claude/question.md | 18 +- .claude/research/documentation-content-map.md | 31 ++- .claude/research/pre-implementation-audit.md | 10 +- CLAUDE.md | 208 +++++++++++++++- ROADMAP.md | 61 +++-- 10 files changed, 531 insertions(+), 77 deletions(-) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index fdfbad8..a59f63c 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -135,7 +135,7 @@ quad/ │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) │ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" -│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋)와 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 +│ ├── 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/ diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 5808152..1282285 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -5,7 +5,7 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 `quad-base`가 인터페이스로 소유, `quad-roblox`는 실제 구현만)까지 전부 2026-08-04 세션에서 확정되어 `research/`에서 승격됨(`base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 남은 건 세부 시그니처(dependency -array API, `CreatedRef` 모양) 뿐 — 구현 단계에서 자연히 정리됨. 원본: +array API) 뿐 — 구현 단계에서 자연히 정리됨. 원본: `.claude/initreq/raw-userinput.md` "key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `reference/quad-v1-architecture.md` @@ -177,6 +177,13 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — KV 매치와 무관. + **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 + `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" + 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`은 "빈 슬롯" + 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 + 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 + 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 + 경로가 다르다는 점에 유의. `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. @@ -291,15 +298,21 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 - Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). -- **바인드 방법**: children을 배열 아이템으로 넣듯 `CreatedRef` 같은 - 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: - `[1] = CreatedRef(function(inst) ... end)`) — `(v=Ref)` 매치 핸들러가 - 이걸 처리함. **[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 - 마운트 전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — - 아래 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, - 원래 있던 "옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 - 없는 평범한 참가자" 서술은 `archive/ref-phase-option-reversed.md`로 - 옮김. +- **바인드 방법**: children을 배열 아이템으로 넣듯 `Ref(default)`(또는 + `:Callback(fn)`을 미리 걸어둔 `Ref(default):Callback(fn)`) 인스턴스 + 자체를 숫자 키 슬롯에 그대로 넣는 방식 — `(v=Ref)` 매치 핸들러가 이걸 + 처리함. **별도 `CreatedRef` 래퍼 함수는 없음(2026-08-07 아홉 번째 + 세션, 사용자 확인) — `Ref`/`PreRef`가 이미 Compose식 `Type(default)` + 팩토리 생성자로 확정됐으므로("생성자 스타일 확정" 절), 그 결과를 그대로 + children 배열에 놓는 것 자체가 바인드 관용구.** 원래 "`CreatedRef` 같은 + 이름 미정의 래퍼"로 서술했던 것은 Ref가 아직 "instance 얻는 통로"로 + 좁게 정의됐던 시절(2026-08-04)의 잔재였고, 2026-08-06 Ref 일반화 이후 + 래퍼 자체가 불필요해졌는데 이름만 남아있던 것을 이번에 정리함. + **[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 마운트 + 전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — 아래 + "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, 원래 있던 + "옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 없는 평범한 + 참가자" 서술은 `archive/ref-phase-option-reversed.md`로 옮김. - **왜 값이 아니라 콜백인가**: quad는 React처럼 렌더 함수가 계속 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, `useEffect` @@ -348,8 +361,8 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 - 가능(발화 후 해당 인덱스만 nil 처리, Luau의 일반화 for는 성긴 배열도 - 잘 순회함). + 가능(발화 후 해당 인덱스만 **`None`으로 소진** — 아래 구현 디테일의 + 2026-08-07 열 번째 세션 정정 참고, 단순 `nil` 처리는 아님). - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -363,20 +376,38 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`가 `nil`이면 yield, 있으면 yield 안 함. - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume - payload 정정)**: 값이 새로 `:Set()`될 때, 같은 배열 하나를 - `for i, v in <배열> do ... end`로 한 번만 순회하면서 `type(v) == - "thread"`면 `:Wait()`가 만든 대기자로 보고 **`coroutine.resume(v, self)`** - (즉 값이 아니라 **Ref 자기 자신**을 resume 인자로 넘김 — 위 self-반환 - 관용구가 `:Wait()`의 yield 경로에서도 그대로 성립하게 하기 위해, - `coroutine.yield()`의 리턴값이 곧 `self`가 되도록 정정. 세 번째 - 세션 원안은 `value`를 넘기는 것으로 적혀 있었으나 이러면 - `ref:Wait().Value`가 안 풀려서 이번 세션에 정정) 후 `[i] = nil` - (1회성 소진), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 - 여전히 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — - 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 - 가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈 인덱스가 있어도 - 없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도 일반화 `for`가 - 계속 잘 순회하므로 압축(compact)할 필요도 없음). + payload 정정, 열 번째 세션에서 소진 방식 정정)**: 값이 새로 `:Set()`될 + 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 + 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 + **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 + resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield + 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`의 + 리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value`를 + 넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서 + 이번 세션에 정정) 후 **`[i] = None`**(**`nil`이 아님** — 아래 + "왜 `nil`이 아니라 `None`인가" 참고), 아니면 일반 콜백 함수로 보고 + 그냥 `v(value)`(콜백은 여전히 원래 값을 직접 받음, 소진 안 함, 계속 + 유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 + 값 타입 하나로 분기 가능(`type(v) == "thread"` → 대기자, + `type(v) == "function"` → 콜백, 그 외/`None` → 빈 슬롯이라 스킵). + 새 콜백/대기자 등록은 `table.insert`로 끝. + - **왜 `nil`이 아니라 `None`인가(2026-08-07 열 번째 세션, 사용자가 실제 + Luau REPL로 반례 제시 후 정정) — 이전 서술("성긴 배열이어도 일반화 + `for`가 계속 잘 순회하므로 압축 불필요")은 절반만 맞았음.** 대기자/콜백 + 자체는 순서가 안 중요해서(어느 게 먼저 fire되든 상관없이 전부 fire되기만 + 하면 됨) "잘 순회함"까지는 맞았지만, 두 가지를 놓쳤음: (1) 키가 촘촘한 + 저범위 정수(1,2,3,...)에서 벗어나 듬성듬성해지면(`nil`로 지운 슬롯도 + 포함) Luau/Lua 테이블이 그 키들을 해시 파트로 취급해 순회 순서가 해시 + 버킷 순서가 되어버림(사용자가 `{[1]=1,[2222]=2222,[211]=211,...}`류 + REPL 실측으로 확인 — 대기자/콜백 리스트 자체는 이 순서 소실이 문제 + 안 되지만, 순서가 실제로 중요한 다른 배열(`PreRef` pre-pass 등)엔 + 치명적). (2) `table.insert`가 내부적으로 쓰는 `#t`(length 연산자)는 + Lua 명세상 구멍이 있는 테이블에서 **정의되지 않은 동작**이라, 다음 + 콜백/대기자 등록이 엉뚱한 인덱스에 들어가 기존 항목을 덮어쓸 위험이 + 있음 — 이건 대기자/콜백 리스트에도 실제로 해당하는 진짜 버그. + `None`은 `nil`이 아닌 **실재하는 값**이라 그 슬롯이 "차 있다"는 사실 + 자체는 안 바뀌므로 두 문제 다 피함 — 소진된 슬롯도 여전히 non-nil + 값을 갖고 있어 테이블이 "구멍 없는 시퀀스"라는 불변식이 깨지지 않음. - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 @@ -397,12 +428,12 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 풀면 됨 — React `useRef(null)`도 명시적 타입 인자 없이는 같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는 트레이드오프 아님. -- **`CreatedRef`와의 관계**: 둘은 상충하지 않음 — 이 절의 Ref가 범용 - 프리미티브, `CreatedRef(fn)`는 그 위에 얹힌 "children 배열에 넣으면 - dispatch가 자동으로 채워주는" 특수 편의 패턴(quad가 만든 instance에 - 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 기반 + `PreRef` - 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" - 절이 최신. +- **children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계**: + 이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 `Ref` + 인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든 + instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 + 기반 + `PreRef` 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 + 표현, `PreRef` 신설" 절이 최신. - **해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정.** React에서도 자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스 호버/무브 시 `current` 확인 후 라벨 위치를 결정하는 라벨 컨테이너 @@ -422,12 +453,15 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 "범용 값 박스"로 넓어졌으므로, 진행 중인 용어 정리(`question.md` 1번) 때 이름이 여전히 맞는지 같이 재검토할 것. -### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 세션) +### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 +세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 +아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) -**`CreatedRef(fn, {phase="created"|"mounted"})`의 `phase` 옵션 자체를 -없앤다.** 위 "확정된 디스패치 모델" 절에 새로 추가된 두 패스 보장(배열 -파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 -**일반 `Ref`/`CreatedRef`를** 다른 children보다 앞/뒤 어디에 놓느냐가 +**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 +타이밍을 고르게 하던 것 자체를 없앤다.** 위 "확정된 디스패치 모델" 절에 +새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) +덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤 +어디에 놓느냐가 이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어가므로, "마지막에 놓기"만으로 "모든 자식 마운트 후" 의미가 공짜로 나옴. 별도 @@ -472,7 +506,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 없음 — `State`를 UB로 보고 타입으로 막기로 한 것과 정확히 같은 원칙의 재적용. - **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 - `Ref`/`CreatedRef`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 + `Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, @@ -485,7 +519,96 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 앞에 얹히는 것. -- **일반 `Ref`/`CreatedRef`는 계속 Modifier/Store 어디든 자유롭게 + - **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션, 사용자 확인) — + 새 규칙 불필요, 배열 index 순서 그대로.** 같은 인스턴스에 `PreRef`가 + 여럿 있으면, 이 pre-pass는 위 "props 순회 순서" 절이 이미 확정해둔 + "배열 파트는 index 순서대로" 계약을 그대로 재사용해 리터럴 순서대로 + fire하면 됨 — 서로 다른 우선순위/순서 개념을 별도로 만들 필요 없음 + (호이스팅은 "PreRef 전체 대 나머지"에만 적용되는 규칙이지, "PreRef끼리" + 에는 적용될 게 없음 — PreRef끼리는 그냥 평범한 배열 순회). + - **호이스팅의 실제 구현 = "물리적 재배치"가 아니라 "완전히 별도의 + 선행 스캔"(2026-08-07 아홉 번째 세션 후속, 사용자 질문에 답변).** + `Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번 + 순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 + `isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i] + = None`**으로 소진(`nil`이 아님 — 위 "왜 `nil`이 아니라 `None`인가" + 절과 같은 이유, 2026-08-07 열 번째 세션 정정: `nil`로 지우면 그 + 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 + 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 + 보장이라 치명적). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 + **같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상 + `Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이 + 직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용 + 특수 취급). **해시 파트의 `None`(Modifier 필드 명시적 지우기)과는 + 의미가 다름** — 해시 파트 `None`은 "이 프로퍼티 핸들러야, 방금 + 지워진 걸 알고 알아서 처리해"라는 의미 있는 재디스패치(`NoneHandler` + 경유)지만, 배열 파트 `None`은 그냥 "여기 원래 아무것도 없었던 것과 + 같다"는 순수 빈 슬롯 표시라 처리할 핸들러 자체가 없음 — 재디스패치 + 없이 루프가 직접 스킵하는 게 맞음. "호이스팅"은 PreRef를 배열의 맨 + 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 + 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 + 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 + fire됨. **PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성 + 요건인 이유**: 아래 "동적 경로 가드" Handler가 `(v=PreRef)`를 + 매치하면 무조건 `error`를 던지므로, pre-pass가 슬롯을 안 지우면 + 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 + `Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가 + 엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은 + 이 오탐을 막기 위해 반드시 필요. + - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props + 순회 순서" 절은 `{a=1, 2, b=3}`류 **구멍 없는** 테이블에서 배열 + 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 + 번째 세션). 같은 세션에서 사용자가 직접 `{[1]=1,[2222]=2222, + [211]=211,...}`류 **키가 듬성듬성한(sparse)** 테이블을 REPL로 + 실측해, 그런 테이블은 순회 순서가 index 오름차순이 전혀 아님(해시 + 버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) `None`으로 + 소진해 테이블을 "구멍 없이 촘촘한" 상태로 계속 유지하는 전략으로 + 이 위험을 원천 회피함(검증 불필요, 애초에 구멍을 안 만드므로). + **여전히 M0에서 검증해야 하는 건 다른 케이스**: `props.Modifier`/ + `props.Ref`를 caller가 안 넘겨 생기는 리터럴 `nil`-hole(`{nil, ref, + child}`, 위 "지금 할 일" 우선순위1 항목)은 caller가 직접 쓰는 raw + Lua 리터럴이라 프레임워크가 `None`으로 대신 못 채워줌 — 이번 REPL + 실측으로 그 케이스의 실제 위험도가 이전 서술("뒤 항목까지 무시될 수 + 있음", 국소적 피해로 서술돼 있었음)보다 훨씬 큼이 드러남: 구멍이 하나만 + 생겨도 **테이블 전체**가 해시 파트 취급으로 넘어가 그 인스턴스의 + 배열 파트 전체가 순서 보장을 잃을 수 있음. M0 스파이크에서 반드시 + 실측하고, 심각하면 "이런 nil-hole은 raw 리터럴로 하지 말고 항상 + `props.Modifier or Modifier()`처럼 non-nil을 보장하라"는 컨벤션 + 문서화까지 검토할 것. + - **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신, + 새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).** + `Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를 + 도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 + 충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst, + flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미 + 다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서 + 안 좋음). **`flatten(nonFlatten) -> flatten` 함수 자체에 얹는 방안은 + 검토 후 기각** — flatten은 Modifier 값을 합치는 순수 변환(현재 `inst`를 + 안 받음, `research/existing-instance-bind-plan.md`가 다루는 "이미 + 마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 해야 하는가" + 질문이 실제로 열려있어 flatten이 한 인스턴스 생애주기 동안 **여러 번 + 재호출될 가능성이 있음** — 여기에 PreRef fire를 얹으면 재바인드마다 + PreRef가 또 fire되어 "이 인스턴스 하나의 construction 훅"이라는 PreRef의 + 정의 자체가 깨짐. `Dispatch.drive`는 최초 마운트 시 정확히 한 번만 + 불리는 게 이미 전제라 이 위험이 없음. + - **동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러 + (2026-08-07 아홉 번째 세션, 사용자 제안 채택) — 아직 문서화 안 돼 + 있었음, 지금 확정.** 위 "Modifier 필드로 막는 이유"/"Source/Store + 값으로 막는 이유" 절은 **타입 차단**만 다뤘음 — Luau 타입은 런타임에 + 지워지므로(`:Peek`/`Override`/버그로 타입을 우회해 PreRef가 Modifier나 + Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. + 전용 `Handler`를 하나 등록: `{ isHandlable = function(inst,k,v) return + isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children + 배열 리터럴에만 놓을 수 있음") end }` — `NoneHandler`와 같은 결의 + "한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이 + Handler는 **`Dispatch.process`/`getHandler`의 정상 우선순위 스캔에 + 등록**되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의 + `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(`None` 처리, + `nil` 아님)해 정상 두 패스 스캔에 다시 노출되지 않게 하므로, 이 + Handler가 실제로 + 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 + 새어들어온" 버그 케이스뿐 — 그래서 no-op이 아니라 즉시 `error`. +- **일반 `Ref`는 계속 Modifier/Store 어디든 자유롭게 들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분). - **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.** @@ -677,7 +800,7 @@ stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. **결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` 래퍼 타입은 안 만듦 — `state:Observer(fn)`가 반환하는 값 자체가 이미 "children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. -`CreatedRef`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 +`Ref`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 아니라 메소드 `state:Observer(fn)`로 확정** — `state`가 항상 필요한 필수 인자라 `:` 리시버 자리에 자연스럽게 들어가고(다른 형태면 인자 두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), `architecture.md`의 @@ -728,7 +851,7 @@ retract/Destroy되면 자동으로 정리됨. 8번)과 같은 결. `base/effect-plan.md`의 Effect도 동일하게 권장. - **base가 제공하는 것은 `isObserver`류 타입 판별자 하나** — children 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 - `CreatedRef`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 + `Ref`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array 참가자 패턴의 반복. - **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과 @@ -1388,7 +1511,8 @@ function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 -- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님 local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag, - StateTag, SourceTag, StoreTag, SlotTag = {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, ModifierTag = + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} -- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: Brand.set(newHandle, ObserverTag) @@ -1426,6 +1550,22 @@ end 문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource` 불필요" 서술도 같이 정정 대상. +**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 +추가(2026-08-07 열 번째 세션).** 위 코드 예시가 원래 `RefTag`/ +`PreRefTag`/`ModifierTag`를 안 만들어뒀는데, 이 문서 곳곳(PreRef 절의 +`isPreRef(v)`, `component-composition-plan.md`의 `isModifier(v)` 등)이 +이미 이 predicate들이 존재한다고 전제하고 써왔음 — 실제로 만들어야 하는 +게 맞아서 태그 목록에 추가. **`isRef`/`isPreRef`는 `isObserver`와 같은 +단순 항등**(`isRef(x) = Brand.get(x) == RefTag`, `isPreRef(x) = +Brand.get(x) == PreRefTag`) — `isState`처럼 집합 멤버십이 아님, 즉 +**`isRef(preRefInstance)`는 `false`.** 이게 중요한 이유: `PreRef`가 +"Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 해서 `isRef`가 +`PreRef`도 통과시키면, 일반 `(v=Ref)` 매치 핸들러가 `PreRef` 인스턴스도 +집어삼켜버려 위 "PreRef" 절이 요구하는 "일반 Ref 경로를 절대 타면 안 +됨"이 깨짐 — `Ref`/`PreRef`는 State/Source 같은 상하위 관계가 아니라 +서로 배타적인 형제 브랜드. `isModifier`도 같은 단순 항등 +(`Brand.get(x) == ModifierTag`). + **`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** `Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` @@ -1469,10 +1609,6 @@ State ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 모듈 이름** — 방향은 전부 확정, 이름만 구현 단계에서 남음(`On` 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고). -- **`CreatedRef`(가칭)의 정확한 함수 이름** — children 배열에 아이템으로 - 넣는다는 방향, 그리고 타이밍은 옵션 값이 아니라 위치 + `PreRef` 타입으로 - 표현한다는 것까지 확정(위 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` - 신설" 절), 정확한 API 이름만 남음. - **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상). diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 198fdff..c504439 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -198,7 +198,32 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴 명시적으로 원하는 내부 `Frame{...}` 호출의 배열 자리에 다시 꽂아넣음 (`return Frame { props.Modifier, props.Ref, ... }`) — **사용자 확정** ("결과적으로 함수 구현에선 타입을 멀쩡히 지정하는게 더 중요하니 네임드가 -맞는듯"). Compose(named `modifier` 파라미터 강제, 린트로 감시)와 +맞는듯"). + +**⚠️ 필수 관용구 — `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` "왜 `nil`이 아니라 `None`인가" 절 — Luau +REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 테이블 전체에 영향). +그래서 **컴포넌트 저작자는 항상 `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가 발명한 게 아니라 선례가 수렴하는 지점(위 "프레임워크 사례 조사" 절 참고). diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 1a2a7df..8bc8ed8 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -31,6 +31,14 @@ pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 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 값과 얽히는 문제" 절 @@ -112,6 +120,16 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서 낫다고 판단 — **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 diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 9d41fa0..58ff539 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -73,8 +73,9 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 - **독립 존재 가능한 프리미티브** — Source, Ref, Store, Modifier. 다른 무언가 없이 그 자체로 `Type(args)` 팩토리 함수로 만들어짐(`Source(default)`/ - `Ref(default)`/`Store({defaults})`/`mod:UICorner(8)`, 위 "생성자 - 스타일 확정" 참고). + `Ref(default)`/`Store({defaults})`/`Modifier()`, 위 "생성자 + 스타일 확정" 참고 — `Modifier()`는 빈 인스턴스, 실제 필드는 + `mod:UICorner(8)`류 체이닝으로 그 위에 얹음). - **원천에 종속된 파생 데이터** — State, Observer. 자기 혼자 존재할 수 없고 항상 특정 원천(Source/다른 State)에 의존 — 그래서 이 둘은 자유 함수 생성자가 없고, 항상 원천에 대한 메소드 호출로만 얻어진다 diff --git a/.claude/question.md b/.claude/question.md index 2b9d6f1..251e44f 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -64,13 +64,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. -- **`CreatedRef`/`canExecute`(3순위, 사소함)**: `CreatedRef`는 과거분사형이라 - 생성자처럼 안 읽힘. `canExecute`는 실제로 "이 핸들이 아직 살아있나" - 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. +- **`canExecute`(3순위, 사소함)**: 실제로 "이 핸들이 아직 살아있나" 확인인데 + 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. **(2026-08-07 추가)** `PreRef`(children 배열 전용, Modifier/Store에 못 들어가는 Ref 특수화 — `base/bind-system-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절)도 신규 이름이라 이 라운드에 같이 재검토 - 대상. + 대상. (`CreatedRef`는 더 이상 이 목록에 없음 — 별도 이름 자체가 폐기됨, + `Ref(default)`/`PreRef(default)`를 children 배열에 직접 놓는 것으로 + 확정, 아래 "지금까지 확정된 것" 참고.) - **`Ref`(3순위, 2026-08-06 추가)**: 정의가 "quad가 만든 instance를 얻는 통로"에서 "아무 사용자 값이나 담는 범용 값 박스"로 넓어져서(`base/ bind-system-plan.md` "Ref 일반화" 절), 이름이 여전히 넓어진 의미에 @@ -132,10 +133,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 없이 나란히 적용됨.** 왜 이 경우만 예외로 방어하는지 명문화 필요, 또는 Luau에서 실제 타입 차단이 가능한지부터 확인(안 되면 그냥 UB로 격하) — 문서모순 절 + 우선순위2-2. -- **`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 - nil-hole 함정에 그대로 노출됨** — caller가 Modifier/Ref를 안 넘기면 - `{nil, ref, child}`에서 뒤 항목까지 통째로 무시될 수 있는 버그 클래스. - M0 스파이크에 이 케이스(안 넘기는 경우)를 반드시 포함시킬 것 — 우선순위1-5. +- ~~`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`의 실제 구현 방식(Parent==nil vs Connection. Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 44b6109..55e5639 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -34,7 +34,7 @@ 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 참조/마이그레이션용, `CreatedRef(fn)` + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `PreRef`(2026-08-07 세 번째 세션, `phase` 옵션 폐기) (`architecture.md`, `bind-system-plan.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`) @@ -67,13 +67,13 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 그때만 재사용 가치 있음 — 지금 3축 어디에도 해당 없음. ### bind-system-plan.md (943줄, 최대 문서) -- 초심자: Source/Store/State 기본 정의+생성자, State 읽기 전용 규칙 / dot-access가 값 읽기 1급 경로 / `:With`+`:Compute` 최소 사용법 / Ref 기본 개념+`CreatedRef` / 이벤트 self 미채택 기본 규칙+문자열 키 / 인스턴스 생성(제네릭+정적 필드) / 라이브러리 초기화 3줄(`RobloxFactory(QuadBase)`) +- 초심자: Source/Store/State 기본 정의+생성자, State 읽기 전용 규칙 / dot-access가 값 읽기 1급 경로 / `:With`+`:Compute` 최소 사용법 / Ref 기본 개념(children 배열에 직접 놓기, 별도 `CreatedRef` 없음) / 이벤트 self 미채택 기본 규칙+문자열 키 / 인스턴스 생성(제네릭+정적 필드) / 라이브러리 초기화 3줄(`RobloxFactory(QuadBase)`) - api: `state:Observer(fn)` 사용법(→심화: weak-table 내부 인덱싱) / `:Subscribe()`/`:Unsubscribe()` 시그니처(→심화: 강참조 레지스트리 구조) / Ref 일반화 표면 API(→심화: "왜 값이 아니라 콜백인가") / 이벤트 store-bind 존재+권장 안 함 가이드(→심화: 엔지니어링 비용 근거) / 핸들러 4종 계약(`isHandlable`/`priority`/`process`/`retract`) / `Attribute` 특수 키 후보(미확정 명시 필요) - 심화: push-invalidate/pull-recompute 전파 모델+"관측해야 실체화된다" 원칙+`previous` 캐비엇 / **왜 State를 Modifier처럼 플래튼하지 않는가**(이미 문서화 완료, 아래 3번 참고) / Store가 Store를 못 담는 이유 / 이벤트 self 미채택 4가지 근거 / store-bind 재귀 래핑 내부 메커니즘, retract가 Destroy 시 호출 안 되는 이유 / 같은 팩토리 재호출 no-op·다른 팩토리 충돌 에러 내부 안전장치 - skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약 ### component-composition-plan.md / module-lifecycle-plan.md -- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `props.Modifier`/`props.Ref` named parameter 경계 전달 / `InitRoblox(Module)` 팩토리 초기화 +- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `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.Override(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 네이밍 미정 등 열린 질문 메모 @@ -180,6 +180,23 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: 최우선 목표로 뒀는가 — 위 `심화` 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에도 @@ -204,6 +221,14 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: - 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) +- **"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`에도 이미 열린 질문으로 잡혀있음 — 여기선 "확정 전엔 문서화 대상 아님"이라는 표시만 겸함. diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index fd7dcc8..f8a82d4 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -119,7 +119,15 @@ nil-index 크래시 vs 조용한 no-op)가 안 정해져 있음. ### 1-5. `props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴의 nil-hole 함정에 그대로 노출됨 -**위치**: `base/component-composition-plan.md` "최종 결론" 1번 — +**[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`를 안 넘기면 그 값은 diff --git a/CLAUDE.md b/CLAUDE.md index 78f41d7..462cb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -663,7 +663,10 @@ M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. - **콜백/대기자 실행 구현 디테일 추가**: 같은 배열 하나를 한 번의 일반화 `for`로 순회하며 `type(v)=="thread"`면 `coroutine.resume`+ 슬롯 nil 처리(1회성), 함수면 그냥 호출(유지) — 새 등록은 `table.insert` - 로 끝, 성긴 배열이어도 압축 불필요. + 로 끝, 성긴 배열이어도 압축 불필요. **[정정, 2026-08-07 열 번째 세션]** + "슬롯 nil 처리"는 틀림 — 사용자가 Luau REPL로 반례 제시, 실제로는 + `None`으로 소진해야 함(`#t`/`table.insert` 안전성 문제). `base/ + bind-system-plan.md` "왜 `nil`이 아니라 `None`인가" 절이 최신. - v1의 `OnCreated` 특수 DI 키는 이식 안 함 — `Ref():Callback(fn)`으로 완전 대체. @@ -1156,3 +1159,206 @@ Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 아홉 번째 세션 — 코퍼스 전체 정합성 감사·수정, `agent-mistake.md` +신설, `CreatedRef` 이름 완전 폐기 + +여러 세션에 걸쳐 쌓인 stale 참조/자기모순을 서브에이전트 5개 병렬 감사로 +찾아내 전부 수정하고(커밋 `98bd46a`), 이어서 사용자가 직접 발견한 남은 +문제(`CreatedRef` 이름 자체가 더 이상 존재할 이유가 없다는 지적)까지 처리한 +세션. 세 부분으로 나눠 요약: + +**1. 코퍼스 정합성 감사·수정 (커밋 `98bd46a`)**: `CreatedRef`의 `phase` +옵션 폐기가 `bind-system-plan.md` 안에서 세 곳 중 두 곳에 방치돼 있던 것, +`question.md`의 `Ref` 재검토 대상 여부 자기모순, UICorner 숏핸드 개명이 +5개 문서에 전파 안 된 것, `canExecute(handle)` 시그니처 정정이 막 확정된 +직후라 두 곳에 전파 안 된 것, `architecture.md`/`ROADMAP.md`/`CLAUDE.md`의 +stale 문구·누락 참조 등 12개 항목을 수정. `store-semantics.md` 제목도 +"State는 **Store** 위의 캐시 레이어"에서 "State는 **Source** 위의 캐시 +레이어"로 정정(사용자 확인: Source 단독 존재 가능 + Store는 Source들의 +집합이라는 온톨로지가 맞음). `slot-plan.md`의 CRUD 의미론 갭 하나만 +사용자가 다음 세션에서 직접 다루기로 보류. + +**2. `archive/agent-mistake.md` 신설** — 설계 반전(`*-reversed.md`)/기각 +후보(`*-rejected.md`)와 구분되는 세 번째 archive 카테고리: 에이전트가 +문서 작성 중 스스로 낸 개념 혼동을 같은 세션 안에서 정정한 사례 전용 +(`canExecute`/`isHandlable` 혼동, `isSource` 불필요 오판 2건). CLAUDE.md +세션 로그에 전체 경위가 장황하게 남아있던 것 중 최종 결론이 이미 `base/` +문서에 반영돼 중복이던 걸 옮기고 포인터만 남김 — 앞으로도 비슷한 사례가 +생기면 여기로 옮길 것(사용자 확인). + +**3. `CreatedRef` 이름 완전 폐기 — 사용자가 직접 발견.** "Ref가 이미 다 +정해진 것 같은데 `CreatedRef`는 이제 없는 말 아니냐"는 지적: `Source(default)`/ +`Ref(default)`/`Store({defaults})`가 이미 Kotlin Compose식 "타입 이름 +자체가 팩토리 함수" 생성자 스타일로 확정돼 있었는데(2026-08-06 네 번째 +세션), `CreatedRef(fn)`라는 별도 래퍼 이름만 그 확정 이전(2026-08-04, +Ref가 아직 "instance 얻는 통로"로 좁게 정의됐던 시절)의 잔재로 계속 +남아있었던 것 — 실제로는 `Ref(default)`(또는 `PreRef(default)`) +인스턴스 자체를 children 배열 숫자 슬롯에 그대로 놓으면 `(v=Ref)` 매치 +핸들러가 처리하므로, 별도 래퍼 함수가 있을 이유 자체가 없었음. `base/ +bind-system-plan.md`(바인드 방법 절 재작성, "CreatedRef와의 관계" 절 +삭제, "phase 옵션 폐기" 절/열린질문 절 정리) · `ROADMAP.md`(M0/M8 체크 +박스) · `question.md`(용어 재검토 목록에서 제거, 해소로 표시) · +`architecture.md`(소스트리 주석) · `research/documentation-content-map.md` +전부 동기화 완료. `archive/ref-phase-option-reversed.md`(phase 옵션 +자체의 역전 이력)와 CLAUDE.md 이전 세션 로그의 `CreatedRef` 언급은 +당시 기록으로서 정확하므로 그대로 둠 — 역사적 서술과 현재 유효한 설계를 +헷갈리지 않도록 "phase 옵션 폐기" 절 제목에 "이 절이 당시 쓰던 이름 +자체도 이후 폐기됨" 포인터만 추가. + +**부수 작업 — `PreRef`/`Modifier`의 "pre-hook" 태깅 요청 처리.** 같은 +세션 앞부분에서 사용자가 "PreRef와 Modifier는 문서화 시 pre-hook 태그가 +필요해 보인다, hook과 pre-hook의 차이(취소 가능/순서 등록 가능)도 적어 +두면 좋겠다"고 제안 — 이건 런타임 설계가 아니라 문서 사이트 콘텐츠 +분류 아이디어라 base/에 "확정"으로 못박지 않고 `research/ +documentation-content-map.md`(심화 콘텐츠 후보 6번 + "문서화 아직 보류" +목록)에 사용자 원문 프레이밍 그대로 미확정 표시로 남겨둠 — `PreRef`가 +"인스턴스에 뭐가 일어나기 전에 채워진다"는 사실 자체는 이미 확정 +서술돼 있었지만(재확인 후 "메모에서 지워도 됨"으로 답변), "hook"/ +"pre-hook" 용어 채택 여부·`PreRef`의 취소 가능성·복수 `PreRef` 간 순서는 +다음에 사용자가 직접 정해야 base/로 승격 가능. **[정정, 2026-08-07 열 +번째 세션]** 같은 대화에서 "Ref 콜백/대기자 배열은 압축 없이 `self[i] += nil`로만 지워도 된다"는 설계가 이미 정확히 반영돼 있다고 여기 적었던 +건 틀림 — 실제로는 `nil`이 아니라 `None`으로 지워야 함(아래 열 번째 +세션 절 참고), 이때는 아직 발견 전이었음. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). `slot-plan.md` CRUD +의미론과 "hook/pre-hook" 프레이밍 확정만 사용자가 직접 다룰 후보로 남음. + +**같은 세션 후속 — `PreRef` pre-pass 구현 위치·복수 `PreRef` 순서·동적 +경로 가드 확정.** 사용자가 구체적인 구현 방안 세 개를 직접 제시: + +1. **복수 `PreRef` 간 순서는 배열 index 순서 그대로** — 별도 규칙 발명 + 불필요, 위 "props 순회 순서" 절이 이미 확정한 "배열 파트는 index + 순서대로" 계약을 그냥 재사용하면 됨. 호이스팅은 "PreRef 대 나머지" + 에만 적용되는 규칙이지 "PreRef끼리"엔 적용될 게 없음. +2. **pre-pass가 사는 곳 — 새 `Dispatch.*` 함수 대신 이미 확정된 + `Dispatch.drive(inst, flattened)` 자신.** 사용자가 두 대안을 직접 + 제시(`Dispatch.process(inst, flatten, prerefs)`류 신설 함수 vs + `flatten(inst, nonFlatten)` 함수 자체에 얹기) — 검토 결과 둘 다 + 불필요/위험함이 드러남. 전자는 이미 `Handler.process`/`Dispatch.process` + 이름이 다른 뜻으로 확정돼 있어 겹침. 후자(flatten에 얹기)는 사용자가 + "가장 간단해 보인다"고 제안했지만, `research/existing-instance-bind-plan.md`가 + 다루는 "이미 마운트된 Instance 재바인드 시 flatten을 다시 해야 + 하는가"라는 열린 질문이 실제로 flatten이 한 인스턴스 생애주기 동안 + 여러 번 재호출될 가능성을 열어두고 있어서, 거기 PreRef fire를 얹으면 + 재바인드마다 PreRef가 또 fire되어 "이 인스턴스 하나의 construction + 훅"이라는 정의 자체가 깨짐 — 기각. `Dispatch.drive`는 최초 마운트 + 시 한 번만 불리는 게 이미 전제라 이 위험이 없어서 그대로 거기 좁은 + pre-pass 한 줄만 얹으면 충분. +3. **동적 경로로 도착한 `PreRef`는 런타임에도 명시적으로 error — + 지금까지 타입 차단만 문서화돼 있던 빈틈을 채움.** 사용자 제안 + 그대로 채택: `{isHandlable = v is PreRef, process = error(...)}` + 전용 Handler를 정상 우선순위 레지스트리에 등록(`NoneHandler`와 같은 + "한 값 종류 전담" 패턴, 새 메커니즘 아님). 리터럴 배열의 `PreRef`는 + pre-pass가 fire와 동시에 슬롯을 소진시켜 정상 두 패스에 다시 + 노출되지 않으므로, 이 Handler가 실제로 매치되는 경우는 타입 차단을 + 어떻게든 우회한 버그 케이스뿐 — no-op이 아니라 즉시 `error`가 맞음. + +전부 `base/bind-system-plan.md` "PreRef" 절에 반영, `ROADMAP.md` M8 +체크박스 갱신, `research/documentation-content-map.md`의 "복수 PreRef +순서" 미정 표시 제거(해소됨, "취소 가능성"만 계속 미정으로 남김). + +**같은 세션 두 번째 후속 — "호이스팅이 물리적 재배치가 아니라 별도 +선행 스캔"이라는 것과 소진 방식을 명시화(뒤이은 세 번째 후속에서 +`nil`→`None`으로 다시 정정됨, 아래 참고).** 사용자가 "drive에서도 +결국 PreRef를 목록에서 뽑아내야 하는데, 호이스팅 안 되면 PreRef +의미가 사라지는 거 아니냐"고 재질문 — 이전 답변이 `Dispatch.drive`가 +pre-pass를 갖는다고만 하고 정확한 알고리즘을 안 써서 나온 질문. +`Dispatch.drive`가 같은 `flattened` 테이블을 **두 번** 순회한다는 +것으로 답변: (1) pre-pass가 배열 전체를 index 순으로 훑어 `PreRef`를 +fire하며 그 자리에서 슬롯을 소진, (2) 그 다음 평소 두 패스가 같은 +테이블을 다시 순회하되 소진된 슬롯은 자연히 건너뜀. "호이스팅"은 +PreRef를 배열 앞으로 물리적으로 옮기는 게 아니라 "PreRef 전용 선행 +루프가 통째로 먼저 끝난 뒤에야 나머지가 시작된다"는 뜻이라 소스 위치와 +무관하게 항상 먼저 fire됨. **소진이 최적화가 아니라 정확성 요건인 이유도 +명시**: 안 지우면 두 번째 패스가 이미 처리된 PreRef를 `Dispatch.process`로 +다시 넘겨서, 바로 위에서 신설한 "동적 경로 가드" Handler(`(v=PreRef)`→ +`error`)가 정상 사용에도 오탐 에러를 던지게 됨. + +## 2026-08-07 열 번째 세션 — 소진 슬롯을 `nil`이 아니라 `None`으로, +사용자가 Luau REPL 반례로 직접 발견 + +같은 날 이어진 세션. 사용자가 Luau REPL에서 직접 +`for i,v in {[1]=1,[2222]=2222,[211]=211,[131]=131,[3]=3,[6]=6, +[122]=122,[11]=11,[312]=312,[821]=821,[991]=991} do print(i,v) end`을 +돌려 순회 순서가 `1, 6, 122, 11, 991, 2222, 131, 312, 3, 821, 211`로 +나온다는 걸 보여줌 — index 오름차순이 전혀 아님. 이건 위 아홉 번째 +세션에서 "PreRef pre-pass가 fire된 슬롯을 `nil`로 지우면 된다"고 적은 +것과 여섯 번째 세션에서 "Ref 콜백/대기자 배열도 `[i]=nil`로 소진하면 +된다"고 적었던 것 둘 다를 뒤집는 반례 — 키가 촘촘한 저범위 정수에서 +벗어나면(구멍이든 원래 듬성듬성이든) Luau/Lua 테이블이 해시 파트 +취급으로 넘어가 순회가 해시 버킷 순서가 됨. + +**해결 — 소진에 `nil` 대신 `None` 센티널 사용, 전 코퍼스에 전파.** +`None`은 `nil`이 아닌 실재하는 값이라 그 슬롯을 "차 있다"로 유지시켜서 +테이블이 "구멍 없는 시퀀스"라는 불변식이 안 깨짐 — 두 가지를 동시에 +해결: (1) 순서가 실제로 중요한 배열(PreRef pre-pass)의 순서 보장 유지, +(2) `table.insert`가 내부적으로 쓰는 `#t`가 Lua 명세상 구멍 있는 +테이블에서 정의되지 않은 동작이라는 문제(Ref 콜백/대기자 배열이 새 +등록 때 `table.insert`를 씀 — 순서 자체는 원래도 안 중요했지만 이 +`#t` 안전성 문제는 진짜 버그였음). **배열 파트의 `None`은 해시 파트의 +`None`(Modifier 필드 명시적 지우기, `NoneHandler` 경유)과 의미가 +다름** — 배열 파트 `None`은 처리할 핸들러가 없는 순수 빈 슬롯 표시라 +`Dispatch.process`/`NoneHandler`를 안 거치고 두 패스 루프 자신이 직접 +`if v == None then continue end`로 스킵. + +`base/bind-system-plan.md`의 "왜 `nil`이 아니라 `None`인가"(Ref +콜백/대기자 절)와 PreRef pre-pass 절에 반영, `ROADMAP.md` M0/M8 +체크박스 갱신, 위 아홉/여섯 번째 세션 문단에 정정 표시 추가(원문은 +유지, 틀렸던 부분만 짧게 정정 포인터). + +**부수 발견 — `props.Modifier`/`props.Ref` nil-hole 위험도가 이전 +서술보다 큼.** `pre-implementation-audit.md` 1-5가 이미 이 위험을 +"뒤 항목까지 무시될 수 있음"으로 국소적 피해처럼 서술해뒀는데, 이번 +REPL 실측으로 실제로는 구멍이 하나만 생겨도 **그 테이블 전체**가 순서 +보장을 잃을 수 있다는 게 드러남 — M0 스파이크에서 반드시 실측하고, +심각하면 "raw 리터럴 대신 `props.Modifier or Modifier()`로 non-nil +보장" 컨벤션 문서화까지 검토하기로 `ROADMAP.md` M0에 메모 추가. 이 +케이스는 caller가 직접 쓰는 raw Lua 리터럴이라 `None`으로 프레임워크가 +대신 채워줄 수 없어서 별도 해법이 필요함 — `None` 소진 전략과 혼동하지 +말 것. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M0 착수 시 위 +nil-hole 위험도 실측이 우선순위 높아짐. + +**같은 세션 세 번째 후속 — `props.Modifier`/`props.Ref` nil-hole +해법을 실제로 확정, 세션 clear 전 문서 완결성 점검하며 발견한 갭 +3개도 같이 보강.** 사용자가 "컴포넌트에서는 꼭 `or None`이나 +`Modifier()` 같은 걸로 nil 못하게 강제하는 걸 문서화하자"고 요청, 그 +자리에서 결정하고 clear 전 세션 전체를 다시 훑어 새로 알게 됐지만 +아직 문서에 없던 것들을 마저 채움: + +1. **`props.Modifier or None`/`props.Ref or None`을 필수 관용구로 + 확정** — `Modifier()`(빈 modifier 새로 생성)가 아니라 `None`을 쓰는 + 이유는 이미 있는 array-part `None`-스킵 메커니즘(PreRef 논의에서 + 확정)을 그대로 재사용해 새 코드/할당이 하나도 안 늘어나기 때문 — + `flatten`이 `isModifier(None) == false`라 그냥 통과시키고, 이어지는 + 두 패스 루프가 `None`을 만나면 스킵. `base/component-composition-plan.md` + "필수 관용구" 절 신설, `ROADMAP.md` M0/`pre-implementation-audit.md` + 1-5/`question.md`에 반영(1-5는 해소로 표시). +2. **`Modifier()` 바닥 생성자가 문서 어디에도 없었던 갭 발견·보강** — + `Source(default)`/`Ref(default)`/`Store({defaults})`와 나란히 있어야 + 할 "`Type(args)` 팩토리" 4번째 예시가 원래 없었음(이전 아홉 번째 + 세션에 `Modifier.Rounded(8)` stale 참조를 고치면서 실수로 체이닝 + 예시인 `mod:UICorner(8)`로 잘못 채워 넣었던 것도 같이 바로잡음). + `modifier-plan.md` 3번 절에 명시, `store-semantics.md` 예시 목록 + 정정, `ROADMAP.md` M7 체크박스 추가. +3. **`Brand` 태그 목록에 `RefTag`/`PreRefTag`/`ModifierTag`가 빠져있던 + 갭 발견·보강** — 이번 세션 내내 `isPreRef(v)`/`isModifier(v)`를 + 이미 존재하는 predicate처럼 써왔는데 정작 여덟 번째 세션의 `Brand` + 태그 목록엔 없었음. 추가하면서 **`isRef`/`isPreRef`가 `isState`와 + 달리 집합 멤버십이 아니라 단순 항등이라는 것도 명시** — + `isRef(preRefInstance)`가 참이면 일반 `(v=Ref)` 핸들러가 `PreRef`도 + 집어삼켜 PreRef 전용 pre-pass/가드 Handler 설계 전체가 무너지므로 + 반드시 배타적이어야 함. `bind-system-plan.md`의 `Brand` 절, + `ROADMAP.md` M2 체크박스에 반영. +4. **배열 파트 `None`과 해시 파트 `None`(`NoneHandler`)이 같은 센티널인데 + 처리 경로가 다르다는 걸 `None` 센티널 절 자체에 명시적으로 + 교차 참조 추가** — 이전엔 PreRef 절에만 있고 `None` 센티널 원래 + 정의 절엔 이 예외가 안 적혀 있어서, 그 절만 읽으면 모든 `None`이 + `NoneHandler`를 탄다고 오해할 수 있었음. + +전부 커밋 `98bd46a` 이후 아직 커밋 안 된 이번 대화 전체 변경사항에 +포함 — 다음 세션이 새로 알아야 할 건 없음, `ROADMAP.md` M0부터 그대로 +시작. diff --git a/ROADMAP.md b/ROADMAP.md index 7ad5505..5339a96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,15 +28,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) - [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + - 일반 `Ref`/`CreatedRef`의 위치 기반 순서까지 최소 스파이크로 검증 + 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 (2026-08-07 세 번째 세션, `base/bind-system-plan.md` "`phase` 옵션 - 폐기 → 위치로 표현, `PreRef` 신설" 절) + 폐기 → 위치로 표현, `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번) — **caller가 Modifier/Ref를 - 안 넘기는 케이스(Lua 배열 리터럴의 nil-hole 함정, `{nil, ref, child}`처럼 - 뒤 항목이 무시될 수 있는 경우)를 반드시 케이스에 포함** - (`research/pre-implementation-audit.md` 1-5) + (`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/` 갱신 @@ -71,10 +80,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 여덟 번째 세션 정정) - [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ - `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot` - 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등 - 비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째 - 세션 신설) + `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/ + `isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 + 태그 목록에서 빠져있었음, `isRef`/`isPreRef`는 단순 항등이지 + `isState`처럼 집합 멤버십 아님) 전부의 기반. `isNone`만 예외로 + 레지스트리 없이 `x == None` 항등 비교 — `bind-system-plan.md`의 + `Brand` 절, 2026-08-07 여덟 번째 세션 신설) - [ ] `LifetimeHandle.luau`/`PerInstanceState.luau` **인터페이스만**(타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 @@ -136,7 +147,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M7 — Modifier -- [ ] flatten-before-dispatch, immutable `table.clone` 체이닝 +- [ ] `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.Override(mod1, mod2, ...)`(가칭, 구 `Merge`) — 필드별 raw 덮어쓰기, 특별한 State/함수 분기 불필요(`modifier-plan.md` 9번) - [ ] `Override`가 서브타입 관계인 서로 다른 Modifier 타입(예: `FrameModifier`/ @@ -164,10 +180,27 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — `base/bind-system-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절) -- [ ] `CreatedRef` 메커니즘(숫자 슬롯 참가자) +- [ ] `(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)`+소진, 함수면 `v(value)` 호출+유지 — - 같은 배열 하나로 통합). `:Wait(thread?)`는 `thread`가 `nil`이면 + `coroutine.resume(v, self)`+`None`으로 소진(`nil` 아님 — + 2026-08-07 열 번째 세션 정정, `#t`/`table.insert` 안전성), 함수면 + `v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`는 + `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) - [ ] `LifetimeHandle` quad-roblox 실제 구현(Instance 생존 확인, 인터페이스 From c865e99860d59c016b729b359a0d4f2fcb4a84b7 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 8 Aug 2026 00:54:48 +0900 Subject: [PATCH 43/62] =?UTF-8?q?decide(base):=20Relate=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=AF=B8=ED=8B=B0=EB=B8=8C=20=EC=8B=A0=EC=84=A4,=20bi?= =?UTF-8?q?ndLifetime/canExecute=20=ED=99=95=EC=A0=95,=20retract=20?= =?UTF-8?q?=ED=95=84=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Relate(inst-weak 릴레이션, SetWeak/GetWeak/SetStrong/GetStrong, 비싱글톤) 신설 — base.perInstanceState(inst) placeholder를 정식 대체 - bindLifetime(inst,value)/canExecute(inst,value) 탑레벨 함수로 확정, Relate 위에 구현(gcconn/gchold), LifetimeHandle.bind식 네임스페이싱 기각 - canExecute 시그니처를 (handle)에서 (inst,value)로 재정정 — Observer 자신의 Subscribed 상태를 먼저, inst의 gcconn.Connected를 그 다음 확인 - store-bind 재실행 구독 메커니즘 = state:Observer(fn):Subscribe() 재사용으로 명문화 - 핸들러 계약: retract 필드는 no-op이라도 항상 정의(생략 시 핸들러 교체 순간 크래시) — 확정 - question.md/ROADMAP.md/architecture.md/README.md 전체 동기화 --- .claude/README.md | 1 + .claude/base/architecture.md | 7 +- .claude/base/bind-system-plan.md | 64 +++++++++-- .claude/base/lifecycle-pattern.md | 109 ++++++++++++++----- .claude/base/relate-plan.md | 97 +++++++++++++++++ .claude/base/ui-shorthand-plan.md | 4 +- .claude/question.md | 40 ++++--- .claude/research/pre-implementation-audit.md | 11 +- CLAUDE.md | 88 +++++++++++++++ ROADMAP.md | 47 +++++--- 10 files changed, 395 insertions(+), 73 deletions(-) create mode 100644 .claude/base/relate-plan.md diff --git a/.claude/README.md b/.claude/README.md index 30dd6d5..0e6da8e 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -39,6 +39,7 @@ | `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-07 여덟 번째 세션 신설]** `[Tag "Name"] = boolean` — `CollectionService` 얇은 래퍼, `process`가 add/remove 전부 처리, `retract` 불필요 | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute` vs `BooleanAttribute`류)만 미확정 | +| `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 신설) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index a59f63c..c7f0ef3 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -133,8 +133,8 @@ quad/ │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) -│ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) -│ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" +│ ├── 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 @@ -142,8 +142,7 @@ quad/ ├── wally.toml └── src/ ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) - ├── LifetimeHandle.luau # 실제 구현(Instance 생존 확인) - ├── PerInstanceState.luau # 실제 구현(weak-keyed table, Instance 키) + ├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── Handlers/ │ ├── Property.luau │ ├── Event.luau # ReflectionService 기반 자동 판별 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 1282285..d1cae34 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -45,6 +45,14 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 - `retract(inst, key, value)` — 이전 처리를 무르는/멈추는 함수(아래 절, `base/lifecycle-pattern.md` 참고). 모든 핸들러가 의미 있게 구현할 필요는 없음(예: 일반 프로퍼티 핸들러는 보통 no-op). + **`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 + 세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는 + 담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 + 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: Tween↔ + 프로퍼티 교체) 실제로 담당이 바뀌는 순간 `attempt to call a nil value`로 + 바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 + 뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 + 없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가). 디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 @@ -114,14 +122,18 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 - **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는 weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에 - 멈추거나 끝냄). **base가 이걸 범용 유틸로 제공**(`base.perInstanceState(inst)` - 류, 정확한 이름/모양은 구현 단계에서 확정) — 모든 핸들러가 재사용, 각자 - WeakMap을 새로 만들지 않음. `base/lifecycle-pattern.md`의 "생명 바인드 유틸"과 - 짝을 이루는 유틸. **왜 GC-안전한가(2026-08-07 여섯 번째 세션, 명시화)**: - 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 - 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 사라지는 - 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — 별도 - cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 + 멈추거나 끝냄). **[정정, 2026-08-08 세션] `base.perInstanceState(inst)`라는 + 이름/모양은 폐기 — `base/relate-plan.md`의 `Relate` 프리미티브로 구체화됨.** + 각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 하나 두고 + `relate:SetStrong(inst, k, tween)`/`relate:GetStrong(inst, k)`로 저장/조회 — + "모든 핸들러가 WeakMap을 재발명하지 않고 공유 유틸을 쓴다"는 원래 취지는 + 그대로, `Relate`가 그 공유 유틸의 정식 인터페이스. `base/lifecycle-pattern.md`의 + `bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가 다르니 + 별도 `Relate()` 인스턴스). **왜 GC-안전한가(2026-08-07 여섯 번째 세션, + 명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 + 릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 + 사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — + 별도 cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 자동으로 같이 죽는 것까지 포함, `research/tween-plan.md` 참고). - **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 @@ -263,6 +275,42 @@ value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구 상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결. +**"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 +확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 +서술했는데(새 프리미티브를 발명하는 것처럼 읽힐 수 있었음), 실제로는 아래 +"`state:Observer(fn)`" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 +구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음: + +```lua +-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state) +local observer = state:Observer(function() + Dispatch.process(inst, k, state:Get()) +end) +observer:Subscribe() +relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 들고 있어야 함 +``` + +- children-array leaf 부착(`Frame { observer }`)이 **아니라** `:Subscribe()`/ + `:Unsubscribe()` 경로를 씀 — 이 Observer는 핸들러 내부 배관이라 사용자가 + 보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer + 핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 + `:Subscribe()`가 유일한 선택). +- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐** — 이게 위 "이벤트도 + store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 + 이유(새 디스패치 메커니즘 없이 기존 4종 계약만 구현), 다만 그 절이 + 구체적으로 가리키는 재사용 대상은 "재귀 process+retract 래핑 패턴"이었고 + Observer 자체를 구독 메커니즘으로 쓴다는 것까지는 명시가 안 돼 있었던 + 갭이 이번에 메워짐. +- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 + 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 + `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 + 특별 취급됨). +- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 + 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 + 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). +- `relate`는 `base/relate-plan.md`의 `Relate` 인스턴스 — 이 핸들러 모듈 + 톱레벨에 `local relate = Relate()`로 하나 두고 재사용. + Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 자연스럽게 맞음 — `base/slot-plan.md` 참고. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 776a987..f4ab998 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -130,35 +130,90 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). -**`canExecute`의 시그니처는 `(handle) -> boolean`이지 `() -> boolean`이 -아님(2026-08-07 여덟 번째 세션, 정정)** — 처음엔 "바인딩마다 클로즈오버된 -zero-arg 람다"로 적었으나, 그러면 등록마다 클로저를 새로 만들어야 해서 -아래 "base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절의 패턴 -(base는 타입만 갖고, quad-roblox가 `BaseModule`을 뮤테이션해서 실 구현체를 -채워넣음)과 잘 안 맞음 — 그 패턴이 성립하려면 `canExecute`는 **quad-roblox가 -한 번만 주입하는 공유 함수**여야 하고, 그러려면 "어떤 등록을 확인할지"를 -가리키는 인자(`handle`, 아래 gchold 스케치의 Connection이 이 역할)가 있어야 -함. base 입장에선 `handle`은 `any`(엔진마다 실체가 다를 수 있음). +### `bindLifetime`/`canExecute` — 확정(2026-08-08 세션) -**quad-roblox 구현 스케치(rbvm 패턴 재사용, base 결정 아님 — 참고용)**: -Instance당(꼭 하나일 필요는 없지만 보통 그게 싸서 하나로 감) weak-keyed -per-instance 저장소(`base.perInstanceState(inst)`)에 "gchold" 배열을 둠. -그 배열엔 절대 발화하지 않도록 골라 만든 신호에 연결한 Connection을 -넣는데, 이 Connection의 콜백 클로저 안에 실제로 살려두고 싶은 옵저버를 -업밸류로 캡쳐해둠(콜백은 안 불려도 클로저 자체가 살아있는 한 업밸류는 -안 죽음) — `inst`가 GC되면 gchold 배열째로 같이 죽으므로 옵저버도 자연히 -GC됨(`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 weak-keyed -중첩 구조와 같은 원리). `canExecute(handle)`은 이 Connection(또는 이를 -감싼 핸들)을 받아 `.Connected`를 확인하는 정도로 구현될 것. -**미확인 세부사항**: 옵저버 → Connection 역참조를 별도 weak 릴레이션으로 -둘지, 아니면 그냥 Observer 테이블 안 평범한 필드로 넣을지(정적 해싱된 -필드 접근이 weak 테이블 조회보다 싸서 후자가 나을 수 있음) — quad-roblox -구현 단계에서 실측 확인 필요, base 설계에 영향 없음. +**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ +`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ +`canExecute`는 `isState`/`isObserver`처럼 핸들러 작성자가 직접 호출하는 +**1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 감싸면 안 됨 — +`LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 평평한 함수: -이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을 -이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"(weak-keyed per-instance -저장소), 다른 하나는 "언제까지 실행되어도 되는지"(생명 바인드 + canExecute)를 -다룸. 둘 다 base가 제공하는 범용 유틸로 확정. +```lua +bindLifetime(inst: any, value: any): () +canExecute(inst: any, value: any): boolean +``` + +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 gcconn = relate:GetStrong(inst, GCCONN) + if not gcconn then + -- ClassName은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음 + -- (rbvm 패턴 그대로) — 콜백 클로저가 gchold를 업밸류로 캡쳐해 살려둠 + local gchold = {} + 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) + table.insert(gchold, value) -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC +end + +function canExecute(inst, value) + -- Observer/Effect는 자기 바인딩 경로(leaf 부착 또는 :Subscribe())의 + -- 생존 여부를 스스로 알고 있음 — inst가 살아있어도 이 값이 먼저 죽어 + -- 있을 수 있으므로(예: retract가 :Unsubscribe()만 하고 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가 diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md new file mode 100644 index 0000000..3f83073 --- /dev/null +++ b/.claude/base/relate-plan.md @@ -0,0 +1,97 @@ +# Relate — inst와 임의의 값을 weak하게 엮는 범용 릴레이션 프리미티브 + +**상태**: base — 2026-08-08 세션에서 신설, 확정. `base/bind-system-plan.md`의 +"핸들러 내부 상태 저장"과 `base/lifecycle-pattern.md`의 `bindLifetime`/ +`canExecute` 양쪽이 필요로 했던 "`inst`를 weak 키로 하는 저장소"가 지금까지 +`base.perInstanceState(inst)`라는 이름만 있고 인터페이스가 미정인 placeholder로 +남아있던 것 — 이번에 독립 프리미티브로 정식 승격, `perInstanceState`라는 +이름/모양은 폐기. + +## 왜 필요한가 + +Store-bind 핸들러(Tween 등)가 "이전에 만든 것"(실행 중인 Tween, gchold +Connection, gchold 배열 등)에 `retract`/`bindLifetime` 시점에 다시 접근하려면 +그 값들을 `inst`에 매달아 저장해야 함. `inst`가 죽으면 이 저장물도 자동으로 +같이 죽어야(GC-native, `base/lifecycle-pattern.md` 원칙) 하므로 바깥 키(`inst`)는 +weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강하게 붙잡아야 +하는 것**(실행 중인 Tween 인스턴스, gcconn — 안 붙잡으면 존재 이유가 없어짐)과 +**약하게만 참조해도 되는 것**(캐시성 값)이 둘 다 있음 — 이 둘을 하나의 테이블 +`__mode`로는 표현 못 함(Luau/Lua 테이블의 weak 모드는 테이블 전체 단위). + +## 왜 자동으로 강하게 들지 않는가 — 엔진이 결정할 일 + +**Relate 자신은 `inst`도 `value`도 자동으로 홀드하지 않는다** — 어느 쪽을 +얼마나 강하게 들지는 호출부(주로 `quad-roblox`)가 명시적으로 결정해야 +함(2026-08-08 세션, 사용자 확정). 자동으로 결정해버리면 weak 키가 참조하는 +값이 그 키를 다시 강하게 참조하는 사이클(예: 프로퍼티 핸들러가 만든 클로저가 +`inst`를 업밸류로 캡쳐한 채로 그 클로저 자신이 `inst`에 매달린 strong 저장소에 +들어가는 경우)이 너무 쉽게 생김 — 엔진 객체의 실제 생명주기를 아는 쪽 +(`quad-roblox`)만이 "이 값은 strong으로 둬도 안전하다"를 판단할 수 있음. +그래서 `Relate`는 판단을 안 하고 **`SetWeak`/`SetStrong`으로 호출부가 매번 +명시**하게 만드는 얇은 표면만 제공. + +## API (확정) + +```lua +Relate() -> relate -- 생성자, 싱글톤 아님 + +relate:SetStrong(inst: any, key: any, value: any) -- value를 강하게 보관 +relate:GetStrong(inst: any, key: any): any? + +relate:SetWeak(inst: any, key: any, value: any) -- value를 약하게만 참조 +relate:GetWeak(inst: any, key: any): any? +``` + +- **`inst`(첫 인자)는 항상 weak** — 이 자유도는 아예 안 열어둠. 지금까지 + 나온 어떤 유스케이스도 "`inst` 쪽을 strong으로 두고 싶다"가 없었고, 열어두면 + "`Relate`가 실수로 엔진 객체를 영구히 붙잡는" 사고 가능성만 늘어남. + `Weak`/`Strong`은 오직 **`value`의 보관 방식**을 가리킴. +- **비싱글톤 — 생성 가능한 값(`Ref`/`Store`/`Modifier`와 같은 프리미티브 + 컨벤션)**. 각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 + 하나씩 두고 재사용 — 서로 다른 `Relate` 인스턴스라 `key` 네이밍이 모듈 + 간에 겹칠 걱정이 없음(모듈 하나가 감당할 key 개수는 보통 한두 개뿐이라 + `Relate()`를 여러 개 만드는 비용은 무시할 만함). + +## 실제 구조 (확정, 2026-08-08 세션) + +``` +{ [inst(weak)]: { StrongMap: {[key]: value}?, WeakMap: {[key]: value(weak)}? }? } +``` + +- **바깥 테이블 하나**: `inst`로 weak-keyed(`__mode = "k"`), 값은 `{ StrongMap?, WeakMap? }` + 형태의 서브테이블. +- **`StrongMap`/`WeakMap`은 각각 lazy 생성** — `Relate()` 호출 시점엔 아무 + 것도 미리 안 만듦. `inst`당 서브테이블도, 그 안의 `StrongMap`/`WeakMap`도 + **`SetWeak`/`SetStrong`이 처음 불릴 때 인덱싱해보고 없으면 그때 생성**. + 이유(사용자 확정, 성능 근거): Luau가 정적 분석으로 포인터 해싱을 캐싱해서 + 같은 자리에서 여러 번 인덱싱하는 건 이미 꽤 싸지지만, **테이블 생성 + 자체(array+hash part 초기화)는 상대적으로 비쌈** — 안 쓸 `inst`/모드 + 조합에 대해 테이블을 미리 만들어두는 건 순수 낭비. +- **`WeakMap`의 메타테이블은 항상 같은 객체를 재사용**(`{__mode = "v"}`류 + 하나를 모듈 로드 시 한 번만 만들어두고, 모든 `WeakMap` 생성에 그 객체를 + 그대로 `setmetatable`) — 메타테이블 내용이 매번 똑같으니 매번 새로 만들 + + 이유가 없음. `StrongMap`은 메타테이블 자체가 필요 없어 그냥 `{}`. +- `GetWeak`/`GetStrong`은 각각 대응하는 서브맵이 아직 안 만들어졌으면(=한 + 번도 `Set`된 적 없음) 그냥 `nil` 반환 — 서브맵을 만들 필요 없음(읽기가 + 쓰기를 유발하면 안 됨). + +**M2 착수 시 실측 확인**: 위 lazy 생성 전략과 `WeakMap` 공유 메타테이블 +재사용이 실제 Luau에서 기대한 만큼 이득인지, `SetStrong`/`SetWeak`을 아주 +자주 왕복 호출하는 핫패스(예: 매 프레임 store-bind 재실행)에서 서브테이블 +존재 체크 자체가 새 비용이 되지는 않는지 — base 설계에는 영향 없는 순수 +구현 최적화 문제. + +## 대체하는 것 + +- `base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` + placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로. +- `base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute` — gcconn/gchold를 + `Relate`의 `SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로 + strong). + +## 이름 + +`Relate` — 사용자 확정("이름은 Relate 괜찮아요? ... 좋습니다", 2026-08-08 +세션). 다른 프리미티브(`Source`/`Ref`/`Store`/`Modifier`/`Effect`/`Blocker`)와 +같은 "타입 이름이 곧 생성자" 컨벤션 그대로. diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index cc71210..67fd295 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -98,8 +98,8 @@ v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했 다시 세팅하는 정도로 충분. 구현 비용도 낮음: 각 Handler가 `process`에서 "이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미 base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소 -(`base.perInstanceState(inst)`, `base/bind-system-plan.md` "핸들러 내부 -상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween +(`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/bind-system-plan.md` +"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween 객체를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙 (`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨. diff --git a/.claude/question.md b/.claude/question.md index 251e44f..ad8780a 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -58,9 +58,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** **파급 효과(2026-08-06 추가)**: `DI`가 리네임되면 `DI.FrameModifier`류 Modifier 클래스별 타입 프리픽스도 같이 바뀌어야 함 — `DI` 리네임 논의 때 이 연쇄까지 같이 고려할 것. -- **`PerInstanceState`(2순위)**: 핵심 프리미티브 `State`와 이름이 겹쳐서 - 실제로는 완전히 무관한 유틸(인스턴스별 weak-keyed 저장소)인데 혼동 - 유발 가능 — `PerInstanceStorage`/`InstanceData` 등 대안. +- **[해소됨, 2026-08-08 세션]** `PerInstanceState` — 이름 문제 자체가 없어짐. + `State`와 이름이 겹쳐 혼동 유발하던 그 유틸은 `Relate`로 대체·정식 + 승격됨(`base/relate-plan.md`) — 이름도 이미 사용자 확정("Relate 괜찮아요"), + `State`와 안 겹침. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -138,23 +139,34 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 세션)**. `props.Modifier or None`/`props.Ref or None` 관용구를 필수로 확정(`base/component-composition-plan.md` "필수 관용구" 절) — M0에선 이 관용구 자체가 타입/런타임 양쪽에서 문제없이 동작하는지만 검증. -- **`canExecute`/`Connected`의 실제 구현 방식(Parent==nil vs Connection. - Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind - retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6. - **(2026-08-07 여덟 번째 세션 보강)** 시그니처는 `(handle) -> boolean`으로 - 확정(zero-arg 클로저 아님, `base/lifecycle-pattern.md` 참고)됐고 - rbvm식 gchold 스케치(weak per-instance 배열에 절대 안 발화하는 - Connection을 넣어 그 클로저 업밸류로 Observer를 살려두는 방식)도 - 후보로 적어뒀지만, 여전히 스케치 단계 — Observer→Connection 역참조를 - weak 릴레이션으로 둘지 평범한 필드로 둘지 포함, 실측은 그대로 필요. +- **~~`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`/`PerInstanceState` 인터페이스(타입만)를 `ROADMAP.md` + `LifetimeHandle`/`Relate` 인터페이스(타입만)를 `ROADMAP.md` M2로 옮기고, quad-roblox 실 구현만 M8에 남김 — 우선순위1-9 해소. - 그 외(Slot CRUD 의미론 미정의, retract 시 "이전 핸들러" 추적 책임 소재, 우선순위 스캔 동률/매치실패 처리, `:Compute`의 `previous` 인자가 오버엔지니어링일 수 있음, UI shorthand의 기존 UICorner 매칭 기준 등)는 `pre-implementation-audit.md` 본문 참고. +- **[신규, 2026-08-08 세션, 미확인]** `Frame { ref }`/`Frame { observer }`처럼 + children 배열 숫자 슬롯에 직접 놓는 leaf 값을 실제로 매칭·바인드하는 + Handler(`(i:number, v=Ref/Observer/PreRef)`)가 어느 패키지에 사는지 — + `base/bind-system-plan.md:301-303,852-854`는 "`(v=Ref)` 매치 핸들러가 + 처리한다"/"`isObserver`로 판별해 라이프사이클에 묶어준다"까지만 서술하고 + 파일 배치는 안 함(`architecture.md` 소스트리에도 이 Handler가 이름으로 + 안 나와 있음). 제안(미확정): `Ref`/`Observer`/`PreRef` 전부 `inst`를 `any`로 + 취급하는 engine-agnostic 타입이고 process가 `v:Set(inst)`/`LifetimeHandle` + 위임 정도만 하면 되니, `Dispatch/StoreBind.luau`(이미 "범용, 엔진 무관"으로 + 분류)와 같은 층위로 `quad-base`에 두는 게 맞아 보임 — `quad-roblox/Handlers/`가 + 아니라. 사용자 확인 필요, 아직 base에 반영 안 함. ### 3. 낮은 우선순위 @@ -227,6 +239,8 @@ context-rejected.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` | diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index f8a82d4..f07a1ad 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -150,6 +150,13 @@ 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` 훅은 생각보다 덜 중요할 수 있음" 부분. @@ -566,8 +573,8 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 - M9(컴포넌트 합성)이 M7(Modifier)·M8(Ref) 뒤에 오는 순서 — M9는 "M0 스파이크(named-parameter 전달)를 정식 Modifier/Ref로 검증"하는 단계라고 명시돼 있어 뒤늦은 검증이 아니라 의도된 정식화. -- `PerInstanceState` 실제 구현 시점(M8) — 이걸 필요로 하는 핸들러(Tag/ - Attribute/Tween)가 전부 M10/M11이라 순서상 문제없음. +- `Relate`(구 `PerInstanceState`) 실제 구현 시점(M8) — 이걸 필요로 하는 + 핸들러(Tag/Attribute/Tween)가 전부 M10/M11이라 순서상 문제없음. - Slot의 store-bind 의존(M6→M4) 순서. --- diff --git a/CLAUDE.md b/CLAUDE.md index 462cb18..3083ada 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1362,3 +1362,91 @@ nil-hole 위험도 실측이 우선순위 높아짐. 전부 커밋 `98bd46a` 이후 아직 커밋 안 된 이번 대화 전체 변경사항에 포함 — 다음 세션이 새로 알아야 할 건 없음, `ROADMAP.md` M0부터 그대로 시작. + +## 2026-08-08 세션 — `Relate` 신규 프리미티브, `bindLifetime`/`canExecute` +탑레벨 함수로 확정, store-bind 재실행=Observer 재사용 명문화, `retract` +필드 생략 불가 확정 + +사용자가 store-bind/라이프사이클 관련 문서 갭 두 개를 질문하며 시작된 세션 +— 답을 찾는 과정에서 지금까지 이름만 있던 placeholder(`base.perInstanceState`)가 +실제로는 제대로 설계된 적 없는 프리미티브였다는 게 드러나 그 자리에서 +설계까지 확정까지 감. 네 가지로 정리: + +**1. store-bind의 "값이 바뀔 때마다 재귀 process" 구독 메커니즘 = +`state:Observer(fn):Subscribe()` 재사용으로 확정.** 기존 "확정된 디스패치 +모델"/"재실행 래핑" 절이 구독을 추상적으로만 서술해서 마치 새 구독 +프리미티브가 필요한 것처럼 읽혔는데, 실제로는 이미 확정된 Observer(등록 +즉시 1회 실행이라 "최초 적용"과 "이후 갱신"이 공짜로 통일됨, 자기 `Subscribed` +liveness도 이미 있음)를 그대로 쓰면 됨 — `retract`는 `observer:Unsubscribe()` +호출 하나로 끝. 새 구독 메커니즘 발명 없음. `base/bind-system-plan.md` +"Store 바인드는 특수 경우인가" 절 반영. + +**2. `retract` 필드는 no-op이라도 항상 정의해야 함 — 생략 불가로 확정.** +"모든 핸들러가 의미 있게 구현할 필요는 없음(보통 no-op)"이라는 기존 서술이 +"필드 자체를 생략해도 된다"로 오독될 수 있는 갭이었음 — `Dispatch.process`는 +담당 핸들러 타입이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 무조건 +호출하므로, 필드를 생략한 핸들러가 실제로 교체되는 드문 순간(Tween↔프로퍼티 +등)에 `attempt to call a nil value`로 크래시함. `base/bind-system-plan.md` +"핸들러 계약" 절에 명시, M2 체크리스트에 린트 대상으로 추가. + +**3. `Relate` 신규 프리미티브 — `bindLifetime`/`canExecute`(가 의존하는 +per-inst weak 저장소)를 제대로 설계.** 사용자 질문 경위: `Frame { observer }`처럼 +children 배열에 직접 놓는 leaf 케이스와, property store-bind 핸들러가 +**내부에서** 만드는 Observer(배열에 안 들어가므로 그 leaf 부착 경로를 안 탐)를 +처음에 잘못 섞어서 답했다가 사용자가 "state 바인딩은 결국 k,inner v를 +호출하니 i=number,v=observer로 다시 실행 안 된다"고 정정 — 후자는 +`bindLifetime(inst, observer)` 같은 별도 배관이 필요하다는 걸로 이어짐. +이게 `base/lifecycle-pattern.md`가 이미 원 사용자 메모(2026-08-04)로 +갖고 있던 "함수 안에서 만든 옵저버도 GC 대상 되어야 함" 절과 정확히 +같은 문제였음이 드러남 — 그 절이 "범용 유틸이 있어야 한다"까지만 말하고 +실제 인터페이스/이름이 없던 것. + +- **탑레벨 평범한 함수로 확정, 네임스페이스 뒤에 안 숨김** — `bindLifetime(inst,value)`/ + `canExecute(inst,value)`. `Dispatch.process`류는 "시스템 배관"이라 + 네임스페이스가 맞지만 이 둘은 `isState`/`isObserver`처럼 핸들러 작성자가 + 직접 부르는 1급 프리미티브 연산이라 `LifetimeHandle.bind(...)`식으로 + 감싸면 안 된다는 사용자 지적(정확함, 처음 제 제안이 틀렸었음). +- **`canExecute` 시그니처를 `(handle)` 단일 인자에서 `(inst, value)` + 2-인자로 재정정** — Observer 자신의 바인딩 생존(`Subscribed`)과 `inst` + 자체 생존(gcconn)이 독립된 두 조건이라 opaque `handle` 하나로 못 뭉침. + 구현은 `value`가 Observer/Effect면 자기 `Subscribed`부터 확인, 그 다음 + `inst`의 공유 gcconn `.Connected`를 봄. +- **`Relate` — `inst`를 weak 키로 하는 범용 릴레이션, 신규 프리미티브로 + 독립 승격**(`base/relate-plan.md`, 1프리미티브-1파일 컨벤션). `Relate()` + 비싱글톤 생성자 + `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. 핵심 + 결정 세 개, 전부 사용자가 직접 제시: + 1. **자동으로 아무것도 홀드 안 함** — `inst`도 `value`도 Relate 자신은 + 안 붙잡음, weak/strong 여부는 호출부(엔진을 아는 quad-roblox)가 + 매번 명시. 자동으로 정하면 weak 키가 참조하는 값이 그 키로 되돌아 + 강참조하는 사이클이 너무 쉽게 생김. + 2. **`inst`(키) 축은 항상 weak로 고정, 자유도를 안 열어둠** — 강한 키가 + 필요한 유스케이스가 지금까지 하나도 없어서, 그 자유도 자체가 사고 + 가능성만 늘림. `Weak`/`Strong`은 오직 `value` 보관 방식. + 3. **실 구조는 `{ [inst(weak)]: { StrongMap: {[k]:v}?, WeakMap: {[k]:v(weak)}? }? }`, + 둘 다 lazy 생성**(첫 `Set` 호출 시에만 만듦) — Luau가 정적 분석으로 + 포인터 해싱을 캐싱해 반복 인덱싱은 이미 싸지지만 테이블 생성(array+hash + part 초기화) 자체는 비교적 비싸다는 게 이유. `WeakMap`의 메타테이블은 + 매번 새로 안 만들고 공유 객체 하나를 재사용. + - **비싱글톤인 이유**: 각 핸들러 모듈이 자기 톱레벨에 `local relate = + Relate()`를 하나씩 두면 key 네이밍이 모듈 간에 겹칠 걱정이 원천적으로 + 없음(`Ref`/`Store`류와 같은 "생성 가능한 값" 컨벤션). +- **`base.perInstanceState(inst)` 이름/placeholder는 완전히 폐기** — + `Relate`가 그 자리를 정식으로 대체. `bind-system-plan.md`(핸들러 내부 + 상태 저장 절)/`ui-shorthand-plan.md`/`architecture.md`(소스트리, + `Relate.luau`는 quad-base 전체가 순수 Lua라 quad-roblox 재구현 없음)/ + `question.md`(용어 정리 목록에서 `PerInstanceState` 항목 삭제, 이름 + 갈등 자체가 해소됨)/`ROADMAP.md`(M2/M8/병행가능 세 곳) 전부 동기화. + +**4. 아직 안 풀린 것 — `(i:number, v=Ref/Observer/PreRef)` children-array +leaf Handler가 quad-base/quad-roblox 중 어디 사는지.** 3번을 풀다가 +갈라져 나온 별개 질문(`Frame { ref }` 자체를 매칭하는 Handler, store-bind +내부 Observer와는 무관) — 제 제안(엔진 특정 API가 필요 없으니 quad-base, +`Dispatch/StoreBind.luau`와 같은 층위)은 사용자 확인을 못 받은 채 대화가 +3번으로 넘어감. `question.md` 2번에 미확인으로 남김, base에는 반영 안 함 +— 다음에 확인 필요. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M0/M2 스파이크 코드가 +검증해야 할 것 목록에 `Relate`의 lazy 서브테이블 생성/공유 메타테이블 +전략, `bindLifetime`/`canExecute`의 실제 gcconn 트릭이 새로 추가됨 — +`base/lifecycle-pattern.md`/`base/relate-plan.md`의 "실측 필요" 캐비엇 +참고. 4번(Ref/Observer leaf Handler 위치)도 M2 착수 전 확인 대상. diff --git a/ROADMAP.md b/ROADMAP.md index 5339a96..ed7e5ac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,17 +86,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `isState`처럼 집합 멤버십 아님) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등 비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째 세션 신설) -- [ ] `LifetimeHandle.luau`/`PerInstanceState.luau` **인터페이스만**(타입 - 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 - 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 - 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 - 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` 2번 - — 2026-08-07 세 번째 세션에 반영). **`canExecute`는 `(handle: - LifetimeHandle) -> boolean`으로 확정**(바인딩마다 클로저 만드는 - zero-arg가 아니라, quad-roblox가 한 번만 주입하는 공유 함수 — "base - 유틸은 인터페이스, 백엔드가 주입" 패턴과 맞춰야 해서. - `base/lifecycle-pattern.md`의 gchold 스케치 절, 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)`/ + `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`를 봄. `bindLifetime`/`canExecute` 둘 다 네임스페이스 + 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분, + `isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/ + lifecycle-pattern.md`의 "`bindLifetime`/`canExecute` — 확정" 절 참고 +- [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서 + 걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러 + 교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약" + 절, 2026-08-08 세션) - [ ] mock 대상 테스트 ## M3 — Store/State/Source @@ -203,10 +215,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) -- [ ] `LifetimeHandle` quad-roblox 실제 구현(Instance 생존 확인, 인터페이스 - 자체는 M2로 이동됨) -- [ ] `PerInstanceState` quad-roblox 실제 구현(weak-keyed table, 인터페이스 - 자체는 M2로 이동됨) +- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/`canExecute` + 본체(`GetPropertyChangedSignal("ClassName")` 연결 트릭으로 gcconn 확보, + `Relate:SetStrong`으로 gcconn/gchold 저장 — 인터페이스 자체는 M2로 + 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 없음) ## M9 — 컴포넌트 합성 레이어 @@ -229,8 +241,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## 특정 마일스톤에 안 묶이고 병행 가능 -- [ ] 용어 정리 스윕 — `State`/`DI`/`PerInstanceState`/`Slot` 등 - (`.claude/question.md` 1번), 최종 이름 확정되는 대로 아무 시점에나 +- [ ] 용어 정리 스윕 — `State`/`DI`/`Slot` 등(`PerInstanceState`는 `Relate`로 + 대체·해소됨) — `.claude/question.md` 1번, 최종 이름 확정되는 대로 + 아무 시점에나 - [ ] 각 마일스톤 완료 시 `.claude/qa-request/`/`.claude/archive/`에 기록, 필요하면 `CLAUDE.md` "최근 세션 요약"도 갱신 From 9ab63863a931d3ea64f0552aeb281d7a430b5b71 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 8 Aug 2026 01:15:06 +0900 Subject: [PATCH 44/62] =?UTF-8?q?decide(base):=20Dispatch=20=ED=83=91?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=20=EC=8B=B1=EA=B8=80=ED=86=A4=20=ED=99=95?= =?UTF-8?q?=EC=A0=95,=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8B=B1=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=8B=A0=EC=84=A4,?= =?UTF-8?q?=20Handler=20=EC=84=B8=20=EB=B2=88=EC=A7=B8=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=AA=85=EB=AC=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref/Observer/PreRef leaf Handler 위치를 quad-base로 확정하며 question.md 미결 항목 해소. --- .claude/base/architecture.md | 49 ++++++++++++++++++- .claude/base/bind-system-plan.md | 51 +++++++++++++++++++ .claude/base/store-semantics.md | 16 ++++++ .claude/question.md | 19 +++----- CLAUDE.md | 84 +++++++++++++++++++++++++++++++- ROADMAP.md | 4 ++ 6 files changed, 209 insertions(+), 14 deletions(-) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index c7f0ef3..5b8662c 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -85,7 +85,14 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo `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)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를 @@ -132,6 +139,7 @@ quad/ │ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔 │ │ ├── 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`) @@ -161,6 +169,45 @@ quad/ 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.Override(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`, 약 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index d1cae34..06cd5c5 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -262,6 +262,57 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 통해 담당자 기록을 갱신하게만 해두면(`Dispatch.drive`가 별도로 기록 안 하고 `Dispatch.process` 내부에 위임) 자연히 해소됨. +### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) + +`Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`/`Store`/ +`Modifier`처럼 생성자가 있는 프리미티브(예: `Dispatch()`로 인스턴스를 여러 개 +만들 수 있는 것)로 바꿔야 하는지 검토 후 **기각, 지금 형태(모듈 require로 +바로 닿는 flat 탑레벨 함수) 유지로 확정**: + +- **재귀 재-dispatch가 요구하는 필연** — Tween/`NoneHandler`/`Dispatch/ + StoreBind.luau` 전부 자기 `process` 안에서 다시 `Dispatch.process(inst,k, + realv)`를 호출함(위 "확정된 디스패치 모델"/"`None` 센티널" 절). 이게 + 성립하려면 Dispatch가 `canExecute`/`bindLifetime`(`base/ + lifecycle-pattern.md`)과 똑같이 require 한 번으로 바로 닿는 안정된 + 전역이어야 함 — 인스턴스화 가능한 프리미티브로 만들면 모든 Handler + 등록/호출 경로에 Dispatch 핸들을 인자로 계속 실어날라야 하는 스레딩 + 비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐. +- **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두 + 가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약** + (`isHandlable`/`priority`/`process`/`retract` 시그니처만 있는 순수 leaf, + Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`/`Tween.luau`처럼 그 계약을 + **구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존 + 방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau` + (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau`/ + `Tween.luau`(재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 + Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김. + 런타임에서도 마찬가지 — 어떤 handler의 `process`든 실제로 *호출*되는 + 시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가 + 완전히 끝나있어 부트스트랩 문제도 없음. +- **quad-base 자신의 기본 핸들러도 같은 레지스트리를 씀** — `NoneHandler`, + `Dispatch/StoreBind.luau`("범용, 엔진 무관")뿐 아니라, children 배열 + 숫자 슬롯에 `Ref`/`Observer`/`PreRef`를 직접 놓는 leaf 값을 매칭하는 + Handler도 여기 속함(`inst`를 `any`로 취급, 엔진 특정 API 불필요 — + `.claude/question.md`가 2026-08-08 세션에 "quad-base/quad-roblox 중 + 어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base, + `Dispatch/Leaf.luau`, `Dispatch.addHandler`로 등록). quad-roblox의 + Property/Event/Tween 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 + 등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 + 전부 하나의 우선순위 스캔을 공유. +- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 + 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 + 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 + 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 + `BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을 + 그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에 + 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 + 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 + 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 + 스코핑됨, 재설계 불필요"). `New()`가 실제로 생기면 그 시점에 BaseModule + 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 + 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 + 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 58ff539..c493e3c 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -90,6 +90,22 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 속하는지가 생성자 모양(자유 함수 팩토리 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"` 커링의 타입 diff --git a/.claude/question.md b/.claude/question.md index ad8780a..03a2824 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -156,17 +156,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 우선순위 스캔 동률/매치실패 처리, `:Compute`의 `previous` 인자가 오버엔지니어링일 수 있음, UI shorthand의 기존 UICorner 매칭 기준 등)는 `pre-implementation-audit.md` 본문 참고. -- **[신규, 2026-08-08 세션, 미확인]** `Frame { ref }`/`Frame { observer }`처럼 - children 배열 숫자 슬롯에 직접 놓는 leaf 값을 실제로 매칭·바인드하는 - Handler(`(i:number, v=Ref/Observer/PreRef)`)가 어느 패키지에 사는지 — - `base/bind-system-plan.md:301-303,852-854`는 "`(v=Ref)` 매치 핸들러가 - 처리한다"/"`isObserver`로 판별해 라이프사이클에 묶어준다"까지만 서술하고 - 파일 배치는 안 함(`architecture.md` 소스트리에도 이 Handler가 이름으로 - 안 나와 있음). 제안(미확정): `Ref`/`Observer`/`PreRef` 전부 `inst`를 `any`로 - 취급하는 engine-agnostic 타입이고 process가 `v:Set(inst)`/`LifetimeHandle` - 위임 정도만 하면 되니, `Dispatch/StoreBind.luau`(이미 "범용, 엔진 무관"으로 - 분류)와 같은 층위로 `quad-base`에 두는 게 맞아 보임 — `quad-roblox/Handlers/`가 - 아니라. 사용자 확인 필요, 아직 base에 반영 안 함. +- **[해소됨, 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. 낮은 우선순위 diff --git a/CLAUDE.md b/CLAUDE.md index 3083ada..e47c0fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1443,10 +1443,90 @@ leaf Handler가 quad-base/quad-roblox 중 어디 사는지.** 3번을 풀다가 내부 Observer와는 무관) — 제 제안(엔진 특정 API가 필요 없으니 quad-base, `Dispatch/StoreBind.luau`와 같은 층위)은 사용자 확인을 못 받은 채 대화가 3번으로 넘어감. `question.md` 2번에 미확인으로 남김, base에는 반영 안 함 -— 다음에 확인 필요. +— 다음에 확인 필요. **[해소됨, 같은 날 두 번째 세션]** 아래 절 참고 — +제 원래 제안 그대로 quad-base로 확정. **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M0/M2 스파이크 코드가 검증해야 할 것 목록에 `Relate`의 lazy 서브테이블 생성/공유 메타테이블 전략, `bindLifetime`/`canExecute`의 실제 gcconn 트릭이 새로 추가됨 — `base/lifecycle-pattern.md`/`base/relate-plan.md`의 "실측 필요" 캐비엇 -참고. 4번(Ref/Observer leaf Handler 위치)도 M2 착수 전 확인 대상. +참고. + +## 2026-08-08 두 번째 세션 — Dispatch는 프리미티브가 아니라 탑레벨 싱글톤 확정, +네이밍 케이싱 컨벤션 신설, Handler를 세 번째 카테고리로 명문화 + +같은 날 이어진 세션. 사용자가 위 4번 미결 항목("Ref/Observer/PreRef leaf +Handler가 어디 사는지")을 다시 짚으며 시작 — "Handler도 실제 런타임 값이 +생기는 요소인데 왜 프리미티브로 안 다루나", "Dispatch는 어떻게 되는 거냐, +State 핸들러 안에서 `getHandler`를 부르려면 Dispatch가 이미 존재해야 +하는데" 하는 질문으로 확장돼 Dispatch 자체의 정체성(싱글톤 top-level +함수 모음 vs 인스턴스화 가능한 프리미티브) 논의로 이어짐. 네 가지로 정리, +전부 `base/bind-system-plan.md`/`base/store-semantics.md`/ +`base/architecture.md`/`question.md`/`ROADMAP.md`에 반영 완료: + +**1. Dispatch는 프리미티브가 아니라 탑레벨 싱글톤 — 확정, 지금 형태 유지.** +`Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`처럼 +생성자 있는 프리미티브로 바꿀지 검토했으나 기각. 근거: (a) Tween/ +`NoneHandler`/`StoreBind`가 자기 `process` 안에서 다시 `Dispatch.process`를 +재귀 호출해야 해서, `canExecute`/`bindLifetime`처럼 require 한 번으로 바로 +닿는 안정된 전역이어야 함 — 프리미티브화하면 모든 Handler 호출 경로에 +Dispatch 핸들을 실어날라야 하는 스레딩 비용이 생기는데 지금은 그 비용이 +없음. (b) 사용자가 우려한 "Handler가 Dispatch 원하고 Dispatch가 Handler +원해서 순환참조" 문제는 착시로 확인됨 — "Handler"가 (i) `Handler.luau`의 +순수 타입 계약(leaf, Dispatch를 몰라도 됨)과 (ii) 그 계약을 구현하는 +concrete 값 모듈(`StoreBind.luau`류, 재귀호출 위해 Dispatch를 참조) +두 가지를 가리켜서 헷갈렸던 것 — 의존 방향은 `Handler.luau` ← +`Dispatch/init.luau` ← `StoreBind.luau`로 항상 한쪽으로만 흐름, 사이클 +없음. (c) 모듈 재생성(`New()`)과의 관계도 새 설계가 필요 없음 — 이미 +확정된 "팩토리가 `BaseModule`을 뮤테이션" 패턴을 그대로 따르면 +`_initializedBy` 마커에 대해 이미 나왔던 결론("`New()`가 생기면 각 +인스턴스가 별도 테이블이 되므로 자연히 스코핑됨")이 Dispatch의 handler +레지스트리에도 그대로 적용됨. v1처럼 `require`를 감싸는 `Init(QuadId?)` +방식은 채택 안 함(id 기반 조회 자체가 Ref로 대체되며 이미 기각된 패턴). +`base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절, +`base/architecture.md` 13번 항목에 반영. + +**2. quad-base 기본 핸들러도 전부 같은 `Dispatch.addHandler` 레지스트리를 +공유 — Ref/Observer/PreRef leaf Handler 위치 확정.** `NoneHandler`/ +`Dispatch/StoreBind.luau`뿐 아니라, children 배열 숫자 슬롯에 `Ref`/ +`Observer`/`PreRef`를 직접 놓는 leaf 값을 매칭하는 Handler도 같은 부류 — +`inst`를 `any`로 취급하고 엔진 특정 API가 필요 없으니 quad-base, +`Dispatch/Leaf.luau`로 확정(위 4번 미결 항목 해소). quad-roblox의 +Property/Event/Tween 핸들러도 **같은** 레지스트리에 등록되므로, base +기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 하나의 우선순위 +스캔을 공유한다는 것도 명시적으로 확인됨. `architecture.md` 소스트리에 +`Dispatch/Leaf.luau` 반영, `question.md`/`ROADMAP.md` M2 동기화. + +**3. Handler는 "독립 프리미티브 vs 파생 데이터" 분류의 세 번째, 별개 +카테고리 — 명문화.** 2026-08-06 후속 세션이 확정한 분류(Source/Ref/Store/ +Modifier=독립 프리미티브, State/Observer=파생 데이터)에 Handler가 왜 +안 끼는지 사용자가 재확인 요청 — 이유: Handler는 그 자체로 구현체가 +없는 **순수 타입 계약**이라 quad 사용자가 다루는 리액티브 값이 아님, +계약을 만족하는 값(`PropertyHandler`류)은 항상 **구현하는 쪽**(base +자신의 기본 핸들러 또는 quad-roblox 백엔드)이 채워 넣는 것이지 `Type(args)` +자유 함수로 사용자가 만드는 게 아니고, State/Observer처럼 어떤 원천에 +종속된 파생물도 아님. `base/store-semantics.md`의 "일반 원칙" 절 뒤에 +"세 번째 카테고리 — Handler" 절로 반영. + +**4. 네이밍 케이싱 컨벤션 신설 — 지금까지 나온 모든 이름이 이미 따르고 +있던 규칙을 문서화만 함, 리네임 없음.** 사용자 관찰: "탑레벨 함수는 +변수처럼 소문자 시작, 프리미티브 타입의 메서드는 대문자 시작(파스칼 +케이싱)이 맞아 보인다"는 규칙 제안 — 검증 결과 기존 이름 전체(생성자 +`Source`/`Ref`/`Store`/`Modifier`/`Relate`/`Effect`, 콜론 메서드 +`:Get`/`:With`/`:Set`/`:Apply`/`:Subscribe`류는 전부 대문자, `canExecute`/ +`bindLifetime`/`isState`류/`Dispatch.process`류/`Brand.set`류는 전부 +소문자)가 이미 예외 없이 이 규칙을 따르고 있었음이 확인됨. 유일하게 +애매해 보였던 `Modifier.Override(mod1, mod2, ...)`(콜론 아니고 dot-access +인데 대문자)도 규칙 위반이 아니라 세 번째 하위 규칙으로 설명됨 — 콜론 +메서드는 아니지만 **`Modifier` 타입 자신의 네임스페이스에 달린 정적 +결합 함수**라 "그 프리미티브 타입 고유의 공개 어휘"라는 점에서 생성자/ +메서드와 같은 부류. 반대로 `Dispatch.process`/`Brand.set`이 소문자인 +이유는 `Dispatch`/`Brand`가 애초에 `Type(args)` 생성자가 없는 프리미티브가 +**아닌** 내부 엔진/레지스트리라서. 최종 판단 기준: "이 이름이 특정 +프리미티브 타입 하나의 전용 소유물인가?" — 그렇다면 대문자, 아니면(여러 +타입에 걸친 범용 유틸이거나 비-프리미티브 엔진 소속) 소문자. `base/ +architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절 신설. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계/문서 정리라 M0 착수 우선순위 자체는 그대로. 위 2026-08-08 첫 세션이 +남긴 "M0/M2 스파이크 검증 목록"에 새로 추가되는 항목 없음. diff --git a/ROADMAP.md b/ROADMAP.md index ed7e5ac..1449589 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,6 +109,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 걸러내기(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는 프리미티브가 아니다" 절) - [ ] mock 대상 테스트 ## M3 — Store/State/Source From 75cae39c7c5c6ae4b177012afa121a5be2fe59ec Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 8 Aug 2026 01:52:18 +0900 Subject: [PATCH 45/62] =?UTF-8?q?docs(base):=20module-lifecycle-plan.md?= =?UTF-8?q?=EC=9D=98=20stale=20=EC=97=B4=EB=A6=B0=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EC=A0=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로바이더 인터페이스 시그니처/네이밍 미정 항목이 Handler 계약 확정으로 이미 풀려 있었는데 반영이 안 되어 있던 것을 사용자가 발견, 동기화. --- .claude/base/module-lifecycle-plan.md | 38 +++++++++++++++++++-------- CLAUDE.md | 9 +++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 7a40c72..92ff1b4 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -84,17 +84,31 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 아니라 하나의 시스템으로 돌 수 있게(pluggable 하게 두자는 논리의 근거이기도 함). `base/architecture.md` 도입부와 동일 결정. -## 열린 질문 (`.claude/question.md`에도 취합) +## 열린 질문이었던 것 — 전부 해소됨 (2026-08-08 두 번째 세션 정리) -- **Store 책임 분리(base vs provider)는 확정됨** — 위 절 참고. 남은 건 실제 - 구현 단계에서 base의 `LifetimeHandle`/재실행 유틸 API를 정확히 어떻게 - 노출할지 정도(설계 방향 자체는 더 이상 열려있지 않음). -- 넘버 바인드/프로바이더 인터페이스의 정확한 함수 시그니처(base가 요구하는 - provider 인터페이스 계약)는 아직 미정 — 구현 착수 시 함께 확정. -- **네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 - 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함 — - 실제로는 `isHandlable`로 받을지 말지 결정하고 우선순위대로 스캔되는 - pluggable 참가자라는 점은 확정, 이름만 미정. +**이 문서 상단 "상태" 줄이 이미 "확정되어 승격됨"이라고 말하고 있었는데도 +이 절 자체는 오래 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는 프리미티브가 + 아니다" 절. - base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 주입한다는 패턴이 확정됨 — 상세는 `base/bind-system-plan.md`의 "base @@ -103,4 +117,6 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 - 스코핑됨, 별도 재설계 불필요. + 스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도 + 그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** — + `base/bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절. diff --git a/CLAUDE.md b/CLAUDE.md index e47c0fe..713ea12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1527,6 +1527,15 @@ Modifier=독립 프리미티브, State/Observer=파생 데이터)에 Handler가 타입에 걸친 범용 유틸이거나 비-프리미티브 엔진 소속) 소문자. `base/ architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절 신설. +**같은 세션 후속 — `module-lifecycle-plan.md`의 "열린 질문" 절이 stale로 +방치돼 있던 것을 사용자가 직접 발견.** 문서 상단 "상태" 줄은 이미 +"확정되어 승격됨"이라고 말하는데 그 아래 "열린 질문" 절은 2026-08-04 +당시 그대로 남아있었음 — 그중 "프로바이더 인터페이스 시그니처 미정"/ +"네이밍 미정(provider/processor/plug)" 두 항목이 사실 그 뒤 `Handler` +계약 확정으로 이미 풀려 있었는데 이 문서에 반영이 안 됐던 것. 원문은 +남기고 각 항목에 해소 표시+포인터 추가, 절 제목도 "열린 질문이었던 것 — +전부 해소됨"으로 정정. 새 결정 아니라 순수 동기화. + **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계/문서 정리라 M0 착수 우선순위 자체는 그대로. 위 2026-08-08 첫 세션이 남긴 "M0/M2 스파이크 검증 목록"에 새로 추가되는 항목 없음. From 54a46aaf7e7ac7b1c3d8ebd0140a6abefb02677c Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 8 Aug 2026 03:23:44 +0900 Subject: [PATCH 46/62] =?UTF-8?q?decide(base):=20Tag=EB=A5=BC=20array-part?= =?UTF-8?q?=20=EA=B0=92=20=EA=B0=9D=EC=B2=B4=EB=A1=9C=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EA=B3=84,=20Dispatch=20=EC=B2=B4=EC=9D=B8+retractUnder?= =?UTF-8?q?=EB=A1=9C=20retract=20=EC=A0=84=ED=8C=8C=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag를 해시 파트 boolean DI 키에서 Modifier식 immutable clone 체이닝 값 객체로 재설계(구 모델은 archive/tag-hash-key-model-reversed.md로 보존). 이 과정에서 재귀 재-dispatch(StoreBind/Tween/NoneHandler)의 retract가 다단 체인까지 정확히 전파되지 않던 설계 공백(pre-implementation-audit.md 1-2번)이 드러나, Dispatch가 (inst,k)별 핸들러 체인을 직접 소유하고 retractUnder로 꼬리부터 정리하는 방식으로 해결. --- .claude/README.md | 2 +- .../archive/tag-hash-key-model-reversed.md | 45 ++++++ .claude/base/architecture.md | 14 +- .claude/base/bind-system-plan.md | 152 ++++++++++++++---- .claude/base/tag-plan.md | 135 ++++++++++++---- .claude/research/pre-implementation-audit.md | 12 +- CLAUDE.md | 89 ++++++++++ ROADMAP.md | 25 ++- 8 files changed, 399 insertions(+), 75 deletions(-) create mode 100644 .claude/archive/tag-hash-key-model-reversed.md diff --git a/.claude/README.md b/.claude/README.md index 0e6da8e..76632e2 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -37,7 +37,7 @@ | `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-07 여덟 번째 세션 신설]** `[Tag "Name"] = boolean` — `CollectionService` 얇은 래퍼, `process`가 add/remove 전부 처리, `retract` 불필요 | +| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. 이제 `retract`가 의미 있음(타입이 바뀌면 전체 삭제, 같은 Tag끼리는 `process`가 diff). 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md` | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute` vs `BooleanAttribute`류)만 미확정 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘 | diff --git a/.claude/archive/tag-hash-key-model-reversed.md b/.claude/archive/tag-hash-key-model-reversed.md new file mode 100644 index 0000000..8807bc0 --- /dev/null +++ b/.claude/archive/tag-hash-key-model-reversed.md @@ -0,0 +1,45 @@ +# [역전됨] Tag = 해시 파트 boolean DI 키(`[Tag "Name"] = true`) — array-part 값 객체로 대체됨 + +**역전 일시**: 2026-08-08 (세 번째 세션). **원 확정 일시**: 2026-08-07 +여덟 번째 세션(`base/tag-plan.md` 최초 작성, "상태: base — 전부 확정"). +**현재 유효한 설계**: `base/tag-plan.md`(전면 재작성됨)가 최종 소스. 이 +파일은 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 "태그 하나당 키 +하나"에서 "여러 태그를 조합하는 값 객체"로 넘어갔는지가 `quadnomicon` +소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**값 모양**: `[Tag "Name"] = boolean | State` — 태그 이름 하나당 +해시 파트 키 하나, 값은 store-bind 가능한 boolean. + +**메커니즘**: `isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 +`TagHandler` 하나로 충분. `process(inst,k,v)`가 `v`가 참이면 `AddTag`, +거짓/`nil`이면 `RemoveTag`. **`retract` 불필요**로 결론 — "값이 뭐든 +(`true`/`false`/`nil`) 항상 같은 `TagHandler`가 이 키를 계속 담당하니 +핸들러 *타입*이 안 바뀐다"는 게 근거였음. + +## 왜 역전됐나 + +사용자가 실사용 시나리오를 제시하며 기각: 상호배타적인 스타일 상태 +(`btn1`/`btn2`/`btn3`류, 실제로는 20개까지도 가능)를 표현하려면 이 모델은 +**태그 이름 개수만큼 boolean 키를 각각 만들어야** 함 — 상태 전환마다 +여러 키를 동시에 갱신해야 하고, 스타일 조합(여러 태그를 합쳐 쓰는 것)도 +자연스럽게 표현이 안 됨. "하나의 값을 통째로 바꿔서 태그 집합을 바꾼다"는 +요구를 이 모델은 구조적으로 못 담음. + +## 대체 모델과의 비교 + +| | 구 모델(해시 파트) | 신 모델(array-part 값 객체) | +|---|---|---| +| 값 모양 | `[Tag "이름"] = boolean` | `Tag(...)`/`Tag.Merged(...)` 값 객체, array 슬롯에 놓임 | +| 상태 전환 | 태그 개수만큼 키 갱신 | 값 하나를 store-bind로 교체 | +| 조합 | 안 됨(키가 독립적) | `:Added`/`:Removed`/`Merged`로 조립 | +| retract | 불필요(핸들러 타입 안 바뀜) | 필요(값이 `nil`이 되면 핸들러 자체가 안 바뀜, 전체 삭제) — `Dispatch` 체인 메커니즘(`bind-system-plan.md` "Dispatch 체인" 절)과 맞물려 재설계됨 | + +부수적으로, 이 역전이 `Dispatch.process`/`retract`의 "이전 매치 핸들러 +추적" 문제(`pre-implementation-audit.md` 1-2번)를 실제로 파고드는 계기가 +됐음 — Tag가 재귀 재-dispatch(`Source`가 store-bind를 거쳐 +TagHandler로 위임)에 진입하는 첫 구체 사례가 되면서, "핸들러 타입이 안 +바뀌니 retract 불필요"라는 구 모델의 전제 자체가 신 모델에서 깨졌고, 그 +자리를 메우려다 `Dispatch.retractUnder`(체인 기반 retract 전파) 설계로 +이어짐. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 5b8662c..05ff75f 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -26,9 +26,12 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기. store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. -4. **PA님 스타일 DI 키 계속 지원**: `[Attribute "Name"]`, `[Tag ""] = true` 같은 - 특수 바인드 키. Tag는 `retract`(구 cleanup, `base/lifecycle-pattern.md` 참고)가 - 내장되어 store 컴퓨티드 바인드도 가능해야 함. +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 매핑이 비현실적"이라는 게 이유. 네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리 @@ -134,9 +137,10 @@ quad/ │ ├── 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`/`Override`(`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 세 번째 세션) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Dispatch/ -│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔 +│ │ ├── 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 두 번째 세션 확정) @@ -155,7 +159,7 @@ quad/ │ ├── Property.luau │ ├── Event.luau # ReflectionService 기반 자동 판별 │ ├── Attribute.luau - │ ├── Tag.luau # CollectionService + │ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) │ ├── Tween.luau # 높은 우선순위 store-bind 핸들러 │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 06cd5c5..d36ec0c 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -92,13 +92,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 그 시점엔 그냥 `Connected`를 보고 무시(no-op). 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. - 3. **`realv`를 들고 다시 `Dispatch.process(inst, k, realv)`를 재귀 호출** - (오케스트레이터 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 - 여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 - 재실행하는 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 - store가 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 - 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 - — 무한 재귀 걱정 없음. + 3. **재귀 호출 전에 먼저 `Dispatch.retractUnder(inst, k, self, realv)`를 + 불러 자기 밑에 위임돼 있던 걸 정리한 뒤, `realv`를 들고 + `Dispatch.process(inst, k, realv)`를 재귀 호출**(정확한 메커니즘은 + 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터 + 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 + 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 + 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 store가 + 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 통과 못 + 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 + 재귀 걱정 없음. - **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — @@ -111,14 +114,19 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행 중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게 - `retract`의 일. **Tag/Attribute는 여기 해당 안 함** — 처음엔 이 - 둘도 예시로 들었으나, 실제로는 UICorner 숏핸드와 같은 패턴(값의 - 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 담당하고, - 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 안 - 일어나서 `retract`가 발화할 조건이 생기지 않음 — 구체 설계는 - `base/tag-plan.md`/`base/attribute-plan.md`. - - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를 - 한 번 호출해주면 됨. + `retract`의 일. **Attribute는 여기 해당 안 함** — UICorner 숏핸드와 + 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 + 담당, 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 + 안 일어남 — `base/attribute-plan.md`. **[정정, 2026-08-08 세 번째 + 세션] Tag는 더 이상 여기 해당하지 않음** — array-part 값 객체로 + 재설계되며(`base/tag-plan.md`, 구 모델은 `archive/ + tag-hash-key-model-reversed.md`) `Tag(...)`↔`nil` 사이에서 핸들러 + 타입 자체가 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 + 바뀌는 diff는 `process`가 담당. + - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 + 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 + 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 + 2026-08-08 세 번째 세션에 구체화됨, 여기 한 줄 설명은 그 요약). - **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는 weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에 @@ -252,15 +260,12 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 "`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상 `Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`가 딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로). -- **M2 착수 시 확인할 것 (`pre-implementation-audit.md` 우선순위1 - "이전에 실제로 매치됐던 핸들러 추적" 항목에 추가)**: "이 키를 지금 누가 - 담당 중인가" bookkeeping은 바깥 순회 루프(`Dispatch.drive`)가 아니라 - `Dispatch.process` 호출 자체 내부에서 갱신돼야 함. 안 그러면 값이 계속 - `None`으로 유지되는 매 사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤 - 실제 담당은 다른 핸들러"로 바깥 루프가 오판해 불필요한 `retract`를 반복 - 호출할 위험이 있음 — `Dispatch.process`가 재귀 호출 시에도 자기 자신을 - 통해 담당자 기록을 갱신하게만 해두면(`Dispatch.drive`가 별도로 기록 안 - 하고 `Dispatch.process` 내부에 위임) 자연히 해소됨. +- **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가" + bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로 + 매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch + 체인" 절의 `chains`/`Dispatch.retractUnder`로 구체화됨 — `NoneHandler`의 + 재귀 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 + 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). ### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) @@ -313,6 +318,92 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. +### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션) + +**문제**: Tween/`NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 +`Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은 +`(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간 +깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와, +A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을 두고 서로 +덮어씀**. 구체적으로: A의 재귀 진입 시점에 슬롯을 A→B로 갱신해두면, A가 +스스로 다시 값을 재계산해 재-dispatch할 때(예: store 값이 또 바뀜) 그 +슬롯엔 이미 B가 적혀있어 "A로 바뀌었다"고 오판해 A 자신을 엉뚱하게 +retract하거나, 반대로 A가 자길 스스로 retract하는 오작동이 남 — 처음 +검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 이 이유로 기각됨(당시 +대화에서 직접 반례로 확인). + +**해법 — Dispatch가 `(inst,k)`별 핸들러 체인(순서 있는 배열)을 소유**: + +```lua +-- Dispatch/init.luau +local chains = Relate() -- {[inst(weak)] = {[k] = {handler, handler, ...}(strong, 순서 있는 배열)}} + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if h then + local list = chains:GetStrong(inst, k) or {} + table.insert(list, h) -- 항상 꼬리에 추가 + chains:SetStrong(inst, k, list) + h.process(inst, k, v) + end +end + +function Dispatch.retractUnder(inst, k, keep, v) + local list = chains:GetStrong(inst, k) + if not list then return end + local cutoff = 0 + if keep then + for i, h in list do if h == keep then cutoff = i break end end + end + for i = #list, cutoff + 1, -1 do + list[i].retract(inst, k, i == cutoff + 1 and v or nil) + list[i] = nil + end +end +``` + +- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst, + k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)`를 + 부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신) + 바로 다음 항목만 실제 `newV`를 받고, 그보다 더 안쪽(다단 체인이 있을 + 경우)은 `nil`을 받음 — 더 안쪽 항목엔 "구체적으로 뭐로 대체됐는지" + 정보가 없고 "완전히 사라진다"는 것만 사실이라서. +- **개별 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안 + 쫓아가도 됨** — `retractUnder`가 꼬리부터 `keep` 앞까지 한 번의 + 루프로 체인 전체를 순서대로 정리해주므로, A→B→C처럼 몇 단계든 각 + 핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨(질문 + 제기됐던 "다단 체인에서 안쪽까지 retract가 안 간다" 문제가 이걸로 + 해소 — `retractUnder`의 루프 자체가 체인 전체를 훑으므로 각 핸들러가 + 수동으로 cascade할 필요가 원천적으로 없음). +- **구멍 걱정 없음** — 이 배열은 항상 꼬리에서만 추가/삭제되는 스택 + 모양이라(`retractUnder`가 항상 꼬리부터 연속으로 지움), "촘촘하지 + 않은 정수 키는 순회 순서가 깨진다"는 문제(위 "PreRef" 절의 `None` + 소진 이슈)가 애초에 발생할 구조가 아님. +- **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화 + 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 + 있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는 + 핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만" + 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건), Tween 같은 + 경우는 자기 `Relate` 저장분만 보고 `Cancel`하면 되니 역시 `v`를 꼭 + 안 봐도 됨 — `v`는 "계약상 항상 주어지지만 안 쓰는 핸들러가 있어도 + 됨" 정도로 이해할 것. +- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 + B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 + 나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그 + 키에서 호출됨을 전제) — 값에 별도 플래그를 심어 의도적으로 순환을 + 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, + 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 + 같은 결로 UB 취급. +- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 + 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 + 미래의 재바인드는 `Dispatch.retractUnder(inst, k, nil, newV); + Dispatch.process(inst, k, newV)` 두 줄로 "이 키의 체인을 통째로 갈아 + 끼우기"가 자연스럽게 됨(각 래핑 핸들러가 자기 전용 `Relate`에 위임 + 대상을 비공개로 숨겨두는 대안 설계는 이게 안 됨 — 대화 중 검토 후 + 기각). `research/debug-tooling-plan.md`의 "무엇이 무엇에 연결됐는가" + 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 + 새로 설계할 필요 없음. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -335,7 +426,8 @@ value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구 ```lua -- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state) local observer = state:Observer(function() - Dispatch.process(inst, k, state:Get()) + Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리 + Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push) end) observer:Subscribe() relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 들고 있어야 함 @@ -346,12 +438,12 @@ relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer 핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 `:Subscribe()`가 유일한 선택). -- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐** — 이게 위 "이벤트도 +- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐 — 위임 대상까지 + 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 밑에 위임된 + 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 핸들러의 `retract`는 + 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 - 이유(새 디스패치 메커니즘 없이 기존 4종 계약만 구현), 다만 그 절이 - 구체적으로 가리키는 재사용 대상은 "재귀 process+retract 래핑 패턴"이었고 - Observer 자체를 구독 메커니즘으로 쓴다는 것까지는 명시가 안 돼 있었던 - 갭이 이번에 메워짐. + 이유(새 디스패치 메커니즘 없이 기존 계약만 구현). - **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index e116ce9..345777f 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -1,46 +1,111 @@ -# Tag 특수 키 — `CollectionService` 얇은 래퍼 +# Tag — array-part 값 객체, `CollectionService` 얇은 래퍼 -**상태**: base — `[Tag "Name"] = true` DI 키의 존재 자체는 `architecture.md` -4번 항목에서 이미 확정. 이 문서는 UICorner 숏핸드(`base/ui-shorthand-plan.md`)/ -Tween(`research/tween-plan.md`)처럼 별도 전용 문서가 없던 걸 2026-08-07 -여덟 번째 세션에 메꾼 것 — Tag/Attribute도 "1 프리미티브 1 파일" 관례 -(Blocker/Effect/Ref/PreRef 분리 선례)를 따라야 한다는 사용자 지적으로 신설. -새 설계 내용은 없음 — 이미 여기저기 흩어져 있던 결정을 한 곳에 모으고, -오늘 논의한 `None`/`process`/`retract` 동작을 반영. +**상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구 +모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존). +새 결정만 반영, 열린 질문 없음. -## 값 모양 +## 왜 재설계됐나 -`[Tag "Name"] = boolean | State` — store-bind 가능(일반 프로퍼티와 -동일하게 취급). PA님의 `EventDrivenProgramming/Observer.luau` -`subscribeTaggedInstance`도 얇은 `CollectionService` 래퍼일 뿐이라 -(`bind-system-plan.md` "PA님 코드와 대조" 절) **Instance 태그는 -`CollectionService` 직접 사용 그대로 유지** — 별도 자체 태그 시스템(v1이 -검토했던 것 같은) 안 만듦. +구 모델(`[Tag "Name"] = boolean`, 태그 하나당 해시 파트 키 하나)은 상호 +배타적인 스타일 상태(`btn1`/`btn2`/`btn3`류, 실사용에서 20개까지도 가능)를 +표현하려면 태그 이름 개수만큼 키를 각각 갱신해야 해서 끔찍함, 스타일 +조합(여러 태그를 합쳐 쓰는 것)도 구조적으로 안 됨 — 상세 경위는 +`archive/tag-hash-key-model-reversed.md`. -## 메커니즘 — 새 아키텍처 개념 불필요 +## 값 모양 — `Modifier`와 같은 immutable clone 체이닝 -`isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 `TagHandler` 하나로 -충분: +``` +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의 + Override(필드 단위 덮어쓰기, 손실 있음)와 + 다른 연산이라 이름도 다름 — Override는 + "이미 계산된 걸 합침", Merged는 "집합을 + 합침" +``` -- `process(inst, k, v)` — `v`가 참이면(`true`) `CollectionService:AddTag(inst, - name)`, 거짓/`nil`이면 `RemoveTag(inst, name)`. `None → nil` 재디스패치 - (`base/bind-system-plan.md`의 `None` 센티널 절)가 그대로 이 경로를 탐 — - `nil`을 "태그 없음"으로 자연스럽게 해석하면 되므로 특별 처리 불필요. -- **`retract` 불필요** — 값이 `true`/`false`/`nil` 무엇이든 항상 같은 - `TagHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜), 추가/제거를 - 전부 `process` 자신이 처리. `retract`는 "매치되는 핸들러 타입 자체가 - 바뀌는" 경우에만 의미 있다는 게 확정된 원칙(`bind-system-plan.md` - "확정된 디스패치 모델" 절, Tween↔일반 프로퍼티가 그 유일한 실사례) — - Tag는 여기 해당 안 됨. **처음엔 이 문서 없이 "확정된 디스패치 모델" - 절이 Tag를 retract 필요 예시로 잘못 들었던 걸 여기서 바로잡음.** +`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 값을 프로그래밍적으로 조립"하는 용도). -UICorner 숏핸드/Tween과 같은 판단 재사용 — 작고 항상 켜져 있어도 비용이 -무시할 만한 기능은 `quad-roblox` 코어에 직접 포함(`base/ui-shorthand-plan.md` -"패키지 배치" 절 참고, 별도 opt-out 패키지로 안 쪼갬). +**동적 토글은 `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) + 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`가 + 불림 — **`v`를 굳이 안 봐도 됨**: retract는 구조상 "더 이상 Tag가 + 아니게 됐을 때만" 불리므로, 뭐가 새로 들어왔든 전체 삭제가 항상 맞는 + 동작. `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`)는 이미 -쓰기 시작한 v1/PA님 관례와 일치해 특별히 재검토 대상 아님. +없음 — 값 모양/메커니즘/retract/패키지 배치 전부 확정. 이름 자체 +(`Tag`/`Added`/`Removed`/`Merged`)는 다른 가칭들과 같이 용어 정리 대상 +(`.claude/question.md`). diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index f07a1ad..54f2b1e 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -66,7 +66,17 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌 `Tween.luau`는 그 위에 얹히는 "값에 tween 설정이 붙어있으면 가로채는" 더 높은 우선순위의 특수 케이스로 재정리하는 게 자연스러워 보임. -### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 +### 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)`를 diff --git a/CLAUDE.md b/CLAUDE.md index 713ea12..f62e722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1539,3 +1539,92 @@ architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절 신설. **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계/문서 정리라 M0 착수 우선순위 자체는 그대로. 위 2026-08-08 첫 세션이 남긴 "M0/M2 스파이크 검증 목록"에 새로 추가되는 항목 없음. + +## 2026-08-08 세 번째 세션 — Tag를 array-part 값 객체로 재설계, Dispatch +체인+`retractUnder`로 재귀 재-dispatch의 retract 전파 문제 해결 + +같은 날 이어진 세션. 사용자가 "Tag를 해시 파트 boolean 키 대신 array-part +값 객체로 바꾸는 게 낫지 않냐"는 질문으로 시작 — 상호배타 스타일 상태 +(`btn1`/`btn2`/`btn3`류, 20개까지도 가능)를 표현하려면 구 모델은 태그 +개수만큼 키를 갱신해야 해서 끔찍하다는 실사용 근거. 이 논의가 "retract가 +새 값의 타입에 따라 이전 핸들러를 정확히 찾아 부를 수 있는가"라는 훨씬 +근본적인 구멍(`pre-implementation-audit.md` 1-2번이 이미 지적해뒀던 것)을 +직접 건드리게 됐고, 몇 차례 시행착오 끝에 사용자가 제시한 "체인+ +`retractUnder`" 설계로 수렴. 세 갈래로 정리: + +**1. Tag 재설계 — array-part 값 객체, `Modifier`와 같은 immutable clone +체이닝.** `Tag(name1, name2, ...)`(가변인자 생성자, 빈 `Tag()`도 유효), +`:Added`/`:Removed`(뮤테이션처럼 안 보이게 `-ed` 어미 — 실제로는 항상 +clone 후 반환), `:Contains(name):boolean`, `:Apply(factory)`(Modifier와 +동일한 순수 체이닝 설탕), `Tag.Merged(tag1,tag2,...)`(집합 합집합, 무손실 +— Modifier의 `Override`는 필드 단위 덮어쓰기라 손실 있음, 그래서 이름도 +다름). `None` 센티널은 불필요로 확인 — 동적 토글은 `Source`/`State`가 +계산 결과로 `nil`을 리턴하면 되는 함수 인자 전달이라 테이블 리터럴의 +nil-hole 문제 자체가 없음(정적 리터럴에서 조건부로 Tag를 넣고 뺄 때는 +다른 array-part 값과 마찬가지로 기존 `None` 관용구가 그대로 유효, Tag +전용 규칙 아님). 구 모델(해시 파트 boolean, "핸들러 타입이 안 바뀌니 +retract 불필요"가 결론이었음)은 `archive/tag-hash-key-model-reversed.md`로 +역전 보존, `base/tag-plan.md` 전면 재작성. 값 타입+API(`Tag.luau`)는 +quad-base, `CollectionService` 글루(`Handlers/Tag.luau`)만 quad-roblox — +이미 확정된 "base는 인터페이스/값, backend는 process·retract 글루" +패턴(`LifetimeHandle`)을 값 타입 수준까지 그대로 확장한 것으로 확인, +새 아키텍처 개념 아님. + +**2. Tag 재설계가 "retract가 실제로 필요해지는" 첫 array-part store-bind +사례가 되며, 기존 "이전 핸들러 추적" 설계 공백이 정면으로 드러남.** +`pre-implementation-audit.md` 1-2번이 이미 "store-bind 재실행 모델에서 +realv 타입이 매 갱신마다 바뀔 수 있는데 '이전 핸들러'를 누가 추적하는지 +불명"이라고 짚어뒀던 것 — Tag가 `Tag(...)`↔`nil` 사이를 오가며 실제로 +핸들러 타입이 바뀌는 구체 사례가 되어 더 이상 미룰 수 없어짐. 시행착오 +과정: +- **1차 제안(제가 냄, 기각됨)**: Dispatch가 `(inst,k)`별로 "지금 누가 + 담당 중인가"를 슬롯 하나로 추적. **재귀/래핑 핸들러(StoreBind 등) + 에서 깨짐** — 사용자가 직접 "A→B 구조에서 A가 바뀌면 B의 retract가 + 실행되고, 재귀로 B로 다시 내려오면 retract가 없는 거 아니냐"고 반례를 + 제시 — A 자신의 생명주기(예: Observer 구독)와 A가 재귀로 위임한 B의 + 생명주기가 슬롯 하나를 두고 서로 덮어써서, A가 스스로 재-dispatch할 + 때 자길 엉뚱하게 retract하거나 반대로 안 해야 할 때 안 하는 오작동이 + 생김이 실제 트레이스로 확인됨. +- **2차 제안(제가 냄, 부분 기각)**: 각 래핑 핸들러가 자기 전용 `Relate`에 + 위임 대상을 비공개로 저장(A→B→C면 A.retract가 수동으로 B.retract를 + 부르고 B.retract가 수동으로 C.retract를 부르는 linked 구조). 동작은 + 하지만 사용자가 두 가지 지적: (a) 나중에 재바인드(`existing-instance- + bind-plan.md`) 지원을 생각하면 위임 정보가 핸들러별로 비공개 분산돼 + 있어 외부에서 못 들여다봄, (b) 각 핸들러 작성자가 "내 retract에서 + 위임 대상도 cascade해야 한다"는 걸 매번 기억해야 하는 규율 의존적 + 설계. +- **최종 채택(사용자 제안) — Dispatch가 `(inst,k)`별 핸들러 체인(순서 + 있는 배열)을 직접 소유, `Dispatch.retractUnder(inst,k,keep,v)`가 + 꼬리부터 `keep` 앞까지 훑으며 정리.** `Dispatch.process`가 매치될 + 때마다 체인에 push, 재귀/래핑 핸들러는 재-dispatch 전에 + `retractUnder(inst,k,self,newV)`를 먼저 불러 자기 밑을 정리 — 이 + 한 번의 루프가 다단 체인(A→B→C) 전체를 순서대로 정리해주므로 개별 + 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안 쫓아가도 + 됨(2차 제안의 (b) 해소), 체인이 Dispatch에 중앙화돼 있어 미래 + 재바인드도 `retractUnder(inst,k,nil,newV);process(inst,k,newV)` + 두 줄로 자연스럽게 됨((a) 해소) — quad-debug의 "무엇이 무엇에 + 연결됐는가" 그래프도 이 구조를 그대로 읽으면 됨. 배열이 항상 꼬리에서만 + 추가/삭제되는 스택 모양이라 `None` 소진 이슈(구멍 있는 정수 키 순회 + 문제)도 애초에 안 생김. **`retract`는 여전히 `(inst,k,v)` 3-인자 + 유지** — 한 차례 제가 "v 제거"를 제안했다가 틀렸음(사용자가 Tag의 + 전체삭제 vs diff 분기를 근거로 정정) — 다만 최종 설계에서 diff는 + `process`(같은 핸들러 유지 시)의 몫이고 `retract`는 항상 "더 이상 + 매치 안 될 때만" 불리므로 Tag 한정으로는 `v`를 안 봐도 항상 전체 + 삭제가 맞다는 것도 확인. 순환은 기존 "일반적 무한루프 방어 안 함" + 원칙(2026-08-04) 그대로 UB. + +**3. 전부 `base/bind-system-plan.md`(신규 "Dispatch 체인" 절 + "확정된 +디스패치 모델"/"None 센티널"/"Store 바인드는 특수 경우인가" 절 갱신)/ +`base/tag-plan.md`(전면 재작성)/`archive/tag-hash-key-model-reversed.md` +(신규)/`base/architecture.md`(소스트리 `Tag.luau` 추가, 4번 항목 정정)/ +`ROADMAP.md`(M2/M4/M10)/`research/pre-implementation-audit.md`(1-2번 +해소 표시)에 반영 완료.** + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M2/M4 스파이크 +검증 목록에 `chains`/`retractUnder`가 다단 체인에서 실제로 정확히 +동작하는지가 새로 추가됨(추론만으로 확정된 것, `pre-implementation-audit.md` +류 "실제 Luau로 부딪혀본 적 없는 것" 범주). `pre-implementation-audit.md` +1-1번(Tween이 유일한 store-bind 예시라 "일반 store-bind와 Tween이 같은 +핸들러인지"가 불명확한 문제)은 Tag가 두 번째 구체 사례가 되면서 정황상 +"별개 핸들러, 둘 다 `Dispatch/StoreBind.luau` 재사용"쪽에 힘이 실리지만 +**아직 명시적으로 확정된 건 아님** — M2/M4 착수 전 마저 확인할 것. diff --git a/ROADMAP.md b/ROADMAP.md index 1449589..456833e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,6 +113,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 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/Tween/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 @@ -143,8 +150,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M4 — 첫 end-to-end 반응형 업데이트 -- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관) -- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" 확인 +- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — 재-dispatch + 전 `Dispatch.retractUnder(inst,k,self,realv)` 호출 필수, `base/ + bind-system-plan.md` "Dispatch 체인" 절) +- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" + + "이전 값이 다른 타입이면 이전 핸들러의 `retract`가 정확히 불린다" + 확인 ## M5 — quad-roblox 최소 프로바이더 @@ -235,7 +246,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) - [ ] `Handlers/Attribute.luau`(`base/attribute-plan.md` — 메커니즘/`None`/ `retract` 불필요 확정, 타입 파라미터화 이름만 착수 전 확인) -- [ ] `Handlers/Tag.luau`(`CollectionService`, `base/tag-plan.md` — 전부 확정) +- [ ] `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 From 1a612ecfa0ad21d50bb850c5616a937936876b7f Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 8 Aug 2026 03:37:32 +0900 Subject: [PATCH 47/62] =?UTF-8?q?decide(base):=20Modifier.Override?= =?UTF-8?q?=EB=A5=BC=20Overridden=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add/Remove→Added/Removed, Merge→Merged와 같은 분사형 네이밍 컨벤션을 Override에도 적용하되, override가 불규칙동사임을 반영해 정확한 과거분사 Overridden을 채택(Overrided는 오기). question.md 용어 재검토 목록에서 제거하고 관련 base/research 문서 전반에 반영. --- .claude/README.md | 2 +- .claude/base/architecture.md | 4 +- .claude/base/bind-system-plan.md | 2 +- .claude/base/component-composition-plan.md | 11 ++-- .claude/base/modifier-plan.md | 66 ++++++++++--------- .claude/base/tag-plan.md | 2 +- .claude/question.md | 16 +++-- .claude/research/documentation-content-map.md | 6 +- .claude/research/pre-implementation-audit.md | 22 +++---- CLAUDE.md | 29 ++++++++ ROADMAP.md | 9 +-- 11 files changed, 102 insertions(+), 67 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index 76632e2..ff5a135 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -31,7 +31,7 @@ | `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 패키지 경계까지 확정 | -| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Override`(구 `Merge`) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정, 이름만 용어 정리 라운드까지 잠정 | +| `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)과 함께 개발. 메커니즘+이름 확정 | diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 05ff75f..0cdca55 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -136,7 +136,7 @@ quad/ │ ├── 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`/`Override`(`base/modifier-plan.md`) +│ ├── 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 세 번째 세션) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Dispatch/ @@ -190,7 +190,7 @@ Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 `:Unsubscribe()`, `relate:SetWeak(...)`/`:GetWeak(...)`/`:SetStrong(...)`/ `:GetStrong(...)`, `mod:FontSize(...)`(필드 setter 체이닝). 3. 프리미티브 타입 자신의 네임스페이스에 달린 정적 결합 함수 — - `Modifier.Override(mod1, mod2, ...)`가 유일한 현재 사례. 콜론 메서드는 + `Modifier.Overridden(mod1, mod2, ...)`가 유일한 현재 사례. 콜론 메서드는 아니지만(여러 Modifier를 동등한 인자로 받아야 해서 self 하나로 안 됨) `Modifier` 타입 고유의 공개 연산이라는 점에서 1/2과 같은 부류 — `Modifier()` 생성자와 같은 이유로 대문자. diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index d36ec0c..453ad95 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -786,7 +786,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store (2026-08-07 아홉 번째 세션, 사용자 제안 채택) — 아직 문서화 안 돼 있었음, 지금 확정.** 위 "Modifier 필드로 막는 이유"/"Source/Store 값으로 막는 이유" 절은 **타입 차단**만 다뤘음 — Luau 타입은 런타임에 - 지워지므로(`:Peek`/`Override`/버그로 타입을 우회해 PreRef가 Modifier나 + 지워지므로(`:Peek`/`Overridden`/버그로 타입을 우회해 PreRef가 Modifier나 Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. 전용 `Handler`를 하나 등록: `{ isHandlable = function(inst,k,v) return isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index c504439..3d1f094 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -263,15 +263,15 @@ Fusion/Vide(named prop 전달, `[Children]`류 예약 키)가 서로 다른 이 가야 하는지는 저작자가 자기 코드에 뭐라고 쓰느냐로 완전히 결정됨(자동 전파가 없기 때문에 성립하는 단순함). -### 3. 여러 modifier를 하나로 합치는 공개 유틸 필요 — `Modifier.Override`(가칭, 2026-08-07 다섯 번째 세션에서 `Merge`→개명, 동작 확정) +### 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.Override(mod1, mod2, ...) -> Modifier`. +결합 함수를 공개 API로 노출: `Modifier.Overridden(mod1, mod2, ...) -> Modifier`. 새 병합 규칙이 아니라 이미 확정된 flatten을 함수로 한 번 더 꺼내 쓸 수 있게 하는 것뿐 — **사용자 요청**("modifier를 합칠 방법도 존재한다면 좋을것 -같아"). `MyComp { Modifier = Modifier.Override(theme, override) }` → 컴포넌트 +같아"). `MyComp { Modifier = Modifier.Overridden(theme, override) }` → 컴포넌트 내부는 항상 이미 합쳐진 단일 값만 받으므로 컴포넌트 저작자가 배열 처리를 신경 쓸 필요 없음. Ref는 필드 충돌 개념이 없어 이 문제 자체가 없음(여러 Ref를 받으면 그냥 전부 실행하면 됨, `modifier-plan.md` §4-2) — 별도 결합 @@ -285,8 +285,9 @@ Ref를 받으면 그냥 전부 실행하면 됨, `modifier-plan.md` §4-2) — - **정확한 API 이름**: `Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 자체도 불확실 — 아마 불필요), `Source`/`State` 독립 생성자·타입 이름, - 컴포넌트 경계용 `props.Modifier`/`props.Ref` 필드명, `Modifier.Override` - 함수명은 전부 가칭. (`GetSource` 계열 접근자는 위 3번 정정으로 아예 + 컴포넌트 경계용 `props.Modifier`/`props.Ref` 필드명은 전부 가칭 + (`Modifier.Overridden`은 2026-08-08 세션에서 이름 확정, 이 목록에서 + 빠짐). (`GetSource` 계열 접근자는 위 3번 정정으로 아예 불필요해짐 — `store.key`가 직접 Source를 반환하므로 별도 접근자 자체가 없음.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/ 생성자 이름 미정)과 같은 급의 후순위 항목 — 구현 단계에서 다른 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 8bc8ed8..756fa30 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -4,9 +4,9 @@ 값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. **Getter는 별도로 안 만들기로 확정(2026-08-06 후속 세션)** — 아래 "4. Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. **팩토리 함수 체이닝 -(`:Apply`), 값 결합(`Override`, 구 `Merge`), 필드 읽기(`:Peek`)+판별 +(`:Apply`), 값 결합(`Overridden`, 구 `Merge`), 필드 읽기(`:Peek`)+판별 (`isState`)은 2026-08-07 세션들에 걸쳐 확정 — 8/9번 절 참고, 한 줄 요약은 -`Apply`="변경을 수행", `Override`="이미 계산된 다른 mod를 합침".** Modifier가 +`Apply`="변경을 수행", `Overridden`="이미 계산된 다른 mod를 합침".** Modifier가 컴포넌트 경계를 어떻게 통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로 **[정정] `research/component-composition-plan.md`는 2026-08-04 세션에 수렴 완료돼 `base/component-composition-plan.md`로 @@ -69,11 +69,11 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 **디스패치 단계**에서 풀린다: - **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.** - Modifier setter/Override/인라인 props 테이블은 `None`을 그냥 평범한 raw + Modifier setter/Overridden/인라인 props 테이블은 `None`을 그냥 평범한 raw 값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이 테이블에 존재하는 값으로 표현이 안 된다"는 것뿐이라, 표현 가능한 실재 센티널만 있으면 기존 merge 규칙("인라인 키 존재 시 무조건 우선", - `Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. + `Overridden`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. 구현 비용이 사실상 0이라 인라인 키/setter 둘 다 여는 데 주저할 이유가 없음(2026-08-07 여덟 번째 세션 확정) — setter로 받으면 "특정 필드만 지우는 재사용 가능한 modifier 조각"(9-1번의 스타일 프리셋 opt-out 시나리오)도 @@ -294,7 +294,7 @@ setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 **권장 관용구, 문서화 필요(2026-08-07 다섯 번째 세션)**: 특정 modifier를 계속 변형/보정하고 싶은 경우(스타일 프리셋, 커링된 팩토리 등)엔 항상 -`Apply`를 기본 선택지로 유도할 것 — 아래 9번의 `Override`는 "이미 따로 +`Apply`를 기본 선택지로 유도할 것 — 아래 9번의 `Overridden`는 "이미 따로 만들어진 modifier 값 두 개 이상을 합쳐야 하는" 경우로만 좁혀서 문서화(용도 구분 절 참고). @@ -306,7 +306,7 @@ setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 저작자 책임. 문서에는 "`:Apply(f)`는 `f(mod)`를 체이닝 문법으로 쓴 것뿐, Apply 자체가 뭔가를 검증하거나 보장해준다고 오해하지 말 것"을 명시. -### 9. Modifier 결합 — `Modifier.Override(mod1, mod2, ...)`, `:Peek`, `isState` (2026-08-07 다섯 번째 세션) +### 9. Modifier 결합 — `Modifier.Overridden(mod1, mod2, ...)`, `:Peek`, `isState` (2026-08-07 다섯 번째 세션) **배경**: `base/component-composition-plan.md` 3번 절이 이미 "여러 modifier를 하나로 합치는 공개 유틸이 필요하다"고 확정하며 `Modifier.Merge` @@ -318,46 +318,46 @@ modifier를 하나로 합치는 공개 유틸이 필요하다"고 확정하며 ` `Apply`로 완전 대체/강제 통합하는 방안도 이번에 검토했으나 이 실사용 니즈를 못 풀어서 기각). 이번 세션에서 실제 동작을 확정. -**이름 변경**: `Merge` → **`Override`로 확정**(사용자 제안). "Merge"는 +**이름 변경**: `Merge` → **`Overridden`로 확정**(사용자 제안). "Merge"는 중립적 합침을 암시하지만 실제 동작은 명시적으로 나중 인자가 이기는 "덮어쓰기"라, 이름이 의미를 정직하게 반영해야 함 — `component-composition-plan.md`의 참조도 이번에 같이 갱신함. **용도를 좁게 문서화할 것 — "진짜 합칠 필요가 있는 경우"로 한정 -(2026-08-07 다섯 번째 세션, 사용자 강조).** `Override`는 범용 조합 +(2026-08-07 다섯 번째 세션, 사용자 강조).** `Overridden`는 범용 조합 도구가 아니라 `Frame{mod1, mod2}`의 컴포넌트 경계판, 그 이상도 이하도 아님 — 이게 없으면 `props.Modifier` 같은 단일 슬롯에 여러 독립 modifier 값을 넣을 방법이 아예 없어지므로 프리미티브로 남겨두는 것뿐. **"특정 -modifier를 계속 바꿔나가고 싶다"는 요구는 `Override`가 아니라 위 8번 +modifier를 계속 바꿔나가고 싶다"는 요구는 `Overridden`가 아니라 위 8번 `Apply`로 풀도록 유도** — 간결한 커링/일급 함수 전달이 기본 관용구가 -되도록, API 문서에서 `Override`를 "값 두 개 이상을 합쳐야 하는 특수 +되도록, API 문서에서 `Overridden`를 "값 두 개 이상을 합쳐야 하는 특수 상황"으로만 소개하고 스타일 변형/보정의 기본 진입점으로는 절대 먼저 보여주지 않을 것. **동작 = 기존 flatten을 함수로 노출한 것, 새 규칙 없음.** -`Modifier.Override(mod1, mod2, ...)`는 뒤 인자가 필드 단위로 이긴다(2번 +`Modifier.Overridden(mod1, mod2, ...)`는 뒤 인자가 필드 단위로 이긴다(2번 절 "배열 순서" 규칙과 동일). 구현은 단순 필드별 덮어쓰기 — 특별한 State/함수 분기가 필요 없음: setter가 이미 호출 시점에 함수를 즉시 실행하고 State 필드는 즉시 `:Compute`로 파생시켜 저장하므로(4번/4-1번), Modifier가 들고 있는 모든 필드는 항상 "그 시점에 이미 완전히 처리된 -(baked) 값"임 — `Override`는 그 baked 값을 필드별로 그대로 교체할 뿐. +(baked) 값"임 — `Overridden`는 그 baked 값을 필드별로 그대로 교체할 뿐. **경고, 반드시 문서화**: baked 값 교체는 그 필드에서 파생된 다른 필드에 소급 반영되지 않는다. 예: `Boldify`가 `Font` 필드를 읽어(`Peek`, 아래 참고) `FontWeight`를 계산해 넣어둔 modifier를, 나중에 `Font`를 바꾸는 -다른 modifier와 `Override`로 합치면 `Font`는 새 값으로 바뀌지만 +다른 modifier와 `Overridden`로 합치면 `Font`는 새 값으로 바뀌지만 `FontWeight`는 예전 `Font` 기준으로 계산된 채 그대로 남는다 — 사용자 실수 범주지만 조용히 틀린 결과가 나오는 케이스라 API 문서(경고 박스)로 -명시 필요. `A:Override(B)`와 `B:Override(A)`가 다른 결과를 낸다는 순서 +명시 필요. `A:Overridden(B)`와 `B:Overridden(A)`가 다른 결과를 낸다는 순서 의존성도 같은 경고 박스에 같이 명시. -**용도 구분 — `Apply` vs `Override`, 둘 다 유지, 서로 대체 안 함**: -한 줄로 요약하면 **`Apply`는 "특정 대상에 대해 변경을 수행한다", `Override`는 +**용도 구분 — `Apply` vs `Overridden`, 둘 다 유지, 서로 대체 안 함**: +한 줄로 요약하면 **`Apply`는 "특정 대상에 대해 변경을 수행한다", `Overridden`는 "특정 대상에 이미 계산된(baked) 다른 mod를 합친다"** — 문서화 시 이 한 문장을 그대로 핵심 구분 기준으로 앞세울 것(2026-08-07 다섯 번째 세션, 사용자 정리). 재사용 가능한 스타일 "변형"(팩토리, 파라미터화 가능)은 `Apply`, 독립적으로 이미 만들어진 modifier "값" 두 개 이상을 한 슬롯에 -밀어넣어야 하는 경우(주로 컴포넌트 경계)는 `Override`. +밀어넣어야 하는 경우(주로 컴포넌트 경계)는 `Overridden`. **9-1. 판단 기준을 "이질적/동질적"이 아니라 "계산 의존성 유무"로 명시할 것, `Apply`를 mutable로 바꾸는 방안은 기각 (2026-08-07 다섯 번째 세션 후속)** @@ -365,7 +365,7 @@ Modifier가 들고 있는 모든 필드는 항상 "그 시점에 이미 완전 **동기**: `Apply` 체이닝이 호출마다 clone을 만들기 때문에, 항목 수천 개짜리 리스트 UI처럼 무거운 Modifier를 대량으로 재생성하는 상황에서 이 clone 비용이 누적되는 게 아닌지 사용자가 우려 — 대안으로 (a) `Apply`/setter를 -아예 mutable로 바꾸는 방안, (b) `Override`를 "여러 값을 합칠 특수 상황"이 +아예 mutable로 바꾸는 방안, (b) `Overridden`를 "여러 값을 합칠 특수 상황"이 아니라 "성능 최적화 수단"으로 승격하는 방안을 검토. **(a) `Apply`를 mutable로 바꾸는 방안 — 기각.** 3번 절에서 immutable+clone을 @@ -396,13 +396,13 @@ setter들은 그 복사본을 그대로 mutate). **기각 이유**: 이렇게 **(b) 판단 기준 — "동질적 vs 이질적 프로퍼티"가 아니라 "필드 간 계산 의존성 유무"로 명시.** 사용자가 처음엔 "동질적(폰트 굵기 보정처럼 연관된 속성끼리)은 `Apply`, 이질적(배경/텍스트/위치처럼 무관한 속성끼리)은 -`Override`"로 구분을 제안했으나, 실제 기준은 주제의 이질성 자체가 아니라 +`Overridden`"로 구분을 제안했으나, 실제 기준은 주제의 이질성 자체가 아니라 **한쪽이 다른 쪽의 이미 baked된 값을 읽어야 하는가(`Peek`으로 데이터가 흘러가는가)**임 — 이질적으로 보여도 계산 의존성이 있으면 `Apply`가 맞고(예: "배경색에 맞춰 텍스트 명도를 자동 보정" — 배경/텍스트라는 이질적 주제인데도 의존성이 있어 `Peek`+`Apply`가 필요), 반대로 동질적으로 보여도 서로 완전히 독립이면(예: 여러 개의 `FontSize` 프리셋 중 하나를 통째로 -갈아끼우는 경우) `Override`도 무방함. `Override`는 필드 단위 raw 교체일 +갈아끼우는 경우) `Overridden`도 무방함. `Overridden`는 필드 단위 raw 교체일 뿐 `Peek`으로 값을 읽어 다른 필드에 반영하는 데이터 흐름이 아예 없으므로 (위 "동작" 절), 계산 의존성이 있는 조합엔 애초에 못 씀 — 이게 진짜 판별 기준. 문서에는 "이질적/동질적"이라는 표면적 구분 대신 이 기준으로 적을 것. @@ -410,19 +410,19 @@ setter들은 그 복사본을 그대로 mutate). **기각 이유**: 이렇게 **실제 최적화 권장 패턴**: 계산 의존성이 없고 재사용 가능한 조각(예: 배경 스타일 하나, 텍스트 스타일 하나, 레이아웃 위치 하나 — 각각 서로 다른 서브시스템/모듈에서 한 번만 만들어지는 값)은 **모듈 상수/한 번만 -생성한 값으로 만들어두고, 인스턴스마다 `Override`로 결합**하는 게 +생성한 값으로 만들어두고, 인스턴스마다 `Overridden`로 결합**하는 게 `Apply` 체인으로 매번 처음부터 다시 파생시키는 것보다 저렴함 — 조각 자체를 -매번 재계산 안 해도 되고, `Override`는 필드별 단순 복사 한 번으로 끝나서 -여러 단계 clone이 누적되는 `Apply` 체인보다 쌈. **주의**: 이건 "`Override`가 -내부적으로 값을 캐싱해준다"는 뜻이 아님 — `Override` 자체엔 캐싱/메모이제이션 +매번 재계산 안 해도 되고, `Overridden`는 필드별 단순 복사 한 번으로 끝나서 +여러 단계 clone이 누적되는 `Apply` 체인보다 쌈. **주의**: 이건 "`Overridden`가 +내부적으로 값을 캐싱해준다"는 뜻이 아님 — `Overridden` 자체엔 캐싱/메모이제이션 같은 새 메커니즘이 전혀 없고(순수 필드 복사), "캐싱"은 그냥 사용자가 조각 Modifier 값을 변수/모듈 상수로 만들어 재사용하는, 기존에도 항상 가능했던 평범한 값 재사용일 뿐 — 라이브러리에 새 캐싱 레이어를 추가하는 게 아니라는 점을 문서에서 분명히 할 것(라이브러리 차원의 자동 메모이제이션은 지금 검토 대상 아님 — 실제로 필요하다고 확인되면 그때 별도로 논의). -**문서 배치**: 초심자 문서엔 `Override`를 아예 안 보여주고(위 "용도를 좁게 -문서화" 절), 이 "언제 `Apply` vs `Override`, 성능 기준" 절 전체는 api/심화 +**문서 배치**: 초심자 문서엔 `Overridden`를 아예 안 보여주고(위 "용도를 좁게 +문서화" 절), 이 "언제 `Apply` vs `Overridden`, 성능 기준" 절 전체는 api/심화 문서 전용 — `research/documentation-content-map.md`의 modifier-plan.md 분류에 반영 완료. @@ -431,14 +431,14 @@ Modifier 값을 변수/모듈 상수로 만들어 재사용하는, 기존에도 근거 없는 선제 최적화라 설계하지 않음 — CLAUDE.md의 "드문 오용/가상 미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 동일. -### 9-2. `Override`가 서로 다른(그러나 상하위 관계인) Modifier 타입을 섞는 경우 — +### 9-2. `Overridden`가 서로 다른(그러나 상하위 관계인) Modifier 타입을 섞는 경우 — 타입 시그니처 미확정, 실 Luau 테스트 필요 (2026-08-07 다섯 번째 세션 후속) **문제**: Modifier 타입은 위 4번 절 "FrameModifier 타입" 언급대로 Roblox 클래스별로 생성기가 뽑아내는 flat 타입인데, 그 밑의 Roblox 클래스 자체엔 서브타입 관계가 있음(`Frame`이 `GuiObject`의 서브클래스) — 그럼 생성된 `FrameModifier`도 `GuiObjectModifier`의 서브타입이어야 자연스럽고, 실제로 -`Modifier.Override(guiObjectMod, frameMod)`처럼 공통 상위 클래스 스타일 +`Modifier.Overridden(guiObjectMod, frameMod)`처럼 공통 상위 클래스 스타일 프리셋과 하위 클래스 전용 보정을 섞어 합치는 패턴이 필요해 보임(사용자 지적, 2026-08-07). @@ -456,7 +456,7 @@ Modifier 값을 변수/모듈 상수로 만들어 재사용하는, 기존에도 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"과 같은 성격의 모호함). **당장의 fallback**: 위 후보안이 Luau에서 실제로 안 먹히는 걸로 확인되면, -`Modifier.Override`의 타입 시그니처를 일단 `Override(...: any): any`류로 +`Modifier.Overridden`의 타입 시그니처를 일단 `Overridden(...: any): any`류로 느슨하게 열어 정적 체크를 포기 — 이건 임시 처치로 명시하고, M7 실제 구현 시점에 실 테스트 결과에 따라 다시 좁히는 걸 목표로 로드맵에 남김 (`ROADMAP.md` M7). @@ -505,6 +505,8 @@ Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSou `base/component-composition-plan.md`에서 해소됨**(named parameter로 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 다루는 "값 자체의 동작"과는 별개 문제였다는 점만 참고로 남김. -- `Override`/`Peek`/`isState` — 동작은 위 9번 절에서 확정, 정확한 이름은 - 다른 가칭들과 마찬가지로 `.claude/question.md`의 용어 정리 라운드까지 - 잠정. +- **[해소됨]** `Overridden` 이름 — 2026-08-08 세션에서 확정(`Add`/`Remove` + →`Added`/`Removed`, `Merge`→`Merged`와 같은 분사형 네이밍 컨벤션에 + 맞춰 불규칙동사 `override`의 정확한 과거분사를 씀, `Overrided`는 오기). + `Peek`/`isState` — 동작은 위 9번 절에서 확정, 정확한 이름은 다른 + 가칭들과 마찬가지로 `.claude/question.md`의 용어 정리 라운드까지 잠정. diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index 345777f..02478ea 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -21,7 +21,7 @@ tag:Removed(name): Tag -- clone 후 이름 제거 tag:Contains(name): boolean -- 멤버십 확인 tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴) Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의 - Override(필드 단위 덮어쓰기, 손실 있음)와 + Overridden(필드 단위 덮어쓰기, 손실 있음)와 다른 연산이라 이름도 다름 — Override는 "이미 계산된 걸 합침", Merged는 "집합을 합침" diff --git a/.claude/question.md b/.claude/question.md index 03a2824..2f1d66d 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -84,11 +84,13 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션 재조정" 절 참고. -- **`Override`/`Peek`/`isState`(3순위, 사소함, 2026-08-07 다섯 번째 - 세션 추가)**: Modifier 결합 유틸(구 `Merge`)과 필드 읽기 접근자, State/ - Source 판별 predicate 세 개의 이름 — 동작은 전부 확정(`base/ - modifier-plan.md` 9번, `base/bind-system-plan.md`의 `isState` 절), - 이름만 다른 가칭들과 같이 용어 정리 라운드에서 재검토. +- **`Peek`/`isState`(3순위, 사소함, 2026-08-07 다섯 번째 세션 추가)**: + 필드 읽기 접근자, State/Source 판별 predicate 두 개의 이름 — 동작은 + 전부 확정(`base/modifier-plan.md` 9번, `base/bind-system-plan.md`의 + `isState` 절), 이름만 다른 가칭들과 같이 용어 정리 라운드에서 재검토. + (`Override`는 이 목록에서 빠짐 — `Overridden`으로 확정, 2026-08-08 + 세션. `Add`/`Remove`→`Added`/`Removed`, `Merge`→`Merged`와 같은 분사형 + 네이밍 컨벤션에 맞춰 불규칙동사 `override`의 정확한 과거분사를 씀.) - **`Bound`(3순위, 사소함, 2026-08-07 일곱 번째 세션 추가)**: Observer/ Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` @@ -231,8 +233,8 @@ context-rejected.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`/`Override`/`Peek`/`isState`) | `base/modifier-plan.md` | -| 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Override`) | `base/component-composition-plan.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` | diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 55e5639..7379632 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -74,7 +74,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 ### component-composition-plan.md / module-lifecycle-plan.md - 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `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.Override(mod1, mod2, ...)` 유틸(구 `Merge`, `props.Modifier` 단일 슬롯용 특수 상황으로 한정 소개 — 아래 modifier-plan.md 절 참고) / Bind는 유일 슬롯(재호출 no-op, 충돌 에러, →심화) / `:With`/`:Compute`로 파생 State 생성 시그니처 / 모듈 싱글톤 스코프 +- 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 네이밍 미정 등 열린 질문 메모 @@ -86,8 +86,8 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 ### 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 `Override`인지 성능 기준) / `:Peek<>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유) -- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) / **왜 `Apply`가 기본이고 `Override`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선) +- api: Setter가 리터럴/변환 함수 둘 다 받음(→심화: getter 없는 이유) / 필드가 State일 수 있는 4가지 조합 표(→심화: 반응성 유지/끊김 이유) / `mod:UICorner(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) / `:Apply(factory)` 기본 체이닝 관용구(→심화: 언제 `Apply` vs `Overridden`인지 성능 기준) / `:Peek<>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유) +- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) / **왜 `Apply`가 기본이고 `Overridden`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선) - 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정 - skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 54f2b1e..b1e7633 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -370,29 +370,29 @@ API"를 얹어야 할 때 Slot의 "own한 것만 CRUD 대상" 불변식 자체 `existing-instance-bind-plan.md`에 한 줄 추가해둘 것 — 기존 "Modifier flatten과 긴장" 캐비엇 옆에 병기. -### 2-5. `Modifier.Override`(구 `Merge`) 시 서로 다른 클래스의 Modifier가 섞이는 게 허용되는지 — **런타임은 해소, 타입 레벨은 여전히 미정** +### 2-5. `Modifier.Overridden`(구 `Merge`) 시 서로 다른 클래스의 Modifier가 섞이는 게 허용되는지 — **런타임은 해소, 타입 레벨은 여전히 미정** -**위치**: `base/modifier-plan.md` "2. Merge 우선순위" + 9번(`Override`) + +**위치**: `base/modifier-plan.md` "2. Merge 우선순위" + 9번(`Overridden`) + `base/component-composition-plan.md` 3번. **[2026-08-07 다섯 번째 세션 갱신] 런타임 동작은 이제 명확함**: `modifier-plan.md` -9번에서 `Override`가 "필드별 raw 덮어쓰기"로 확정됐고, "Modifier는 핸들러 +9번에서 `Overridden`가 "필드별 raw 덮어쓰기"로 확정됐고, "Modifier는 핸들러 계층을 모름 — 순수 데이터 merge 레이어"(1번 절) 원칙도 이미 있었으므로, -**런타임 레벨에서는 대상 클래스가 다른 Modifier끼리 `Override`해도 막을 +**런타임 레벨에서는 대상 클래스가 다른 Modifier끼리 `Overridden`해도 막을 이유가 없음**(필드명만 보고 그대로 덮어쓸 뿐 — Luau 타입은 런타임에 강제되지 않는다는 점은 `store-semantics.md`에도 이미 명시된 전제). **여전히 미정인 것 — 타입 레벨**: Modifier가 target 클래스별 제네릭 -타입(`Modifier` 등)이라면, `Modifier.Override(mod1: Modifier, +타입(`Modifier` 등)이라면, `Modifier.Overridden(mod1: Modifier, mod2: Modifier): Modifier`처럼 같은 `T`만 받도록 타입으로 강제할지, 아니면 공통 base 타입(여러 GuiObject 클래스에 걸친 공통 필드)과 클래스별 -확장 사이의 계층 구조를 별도로 두고 `Override`가 그 계층을 넘나들 수 +확장 사이의 계층 구조를 별도로 두고 `Overridden`가 그 계층을 넘나들 수 있게 할지는 아직 결정된 바 없음 — "공통 테마 Modifier + 클래스별 override -Modifier를 합친다"는 시나리오가 `Override`의 가장 그럴듯한 실사용 +Modifier를 합친다"는 시나리오가 `Overridden`의 가장 그럴듯한 실사용 예시라 이 타입 설계가 실제로 막히면 바로 걸릴 문제. **제안**: Modifier의 클래스별 typed 생성자 계층(2-8번과 같은 지점) 설계 -시 `Override`의 제네릭 시그니처도 같이 확정할 것. M7 착수 시. +시 `Overridden`의 제네릭 시그니처도 같이 확정할 것. M7 착수 시. ### 2-6. Modifier 필드에 State/Source를 인자로 넘기는 케이스가 세터 표에서 빠짐 @@ -412,10 +412,10 @@ Modifier를 합친다"는 시나리오가 `Override`의 가장 그럴듯한 실 ### 2-7. 여러 Ref를 하나의 named parameter로 넘길 때 nested-array flatten 여부 불명 -**위치**: `base/component-composition-plan.md` 3번(`Modifier.Override`, +**위치**: `base/component-composition-plan.md` 3번(`Modifier.Overridden`, 구 `Merge`) vs "Ref는... 별도 결합 유틸 불필요" 문장. -**문제**: Modifier는 여러 개를 합치려면 `Modifier.Override`가 명시적으로 +**문제**: Modifier는 여러 개를 합치려면 `Modifier.Overridden`가 명시적으로 필요한데, 바로 다음 문장은 Ref는 "여러 Ref를 받으면 그냥 전부 실행하면 됨 — 별도 결합 유틸 불필요"라고 한다. `props.Ref = {ref1, ref2}`처럼 배열을 넘기면 리프 디스패처가 그 중첩 배열을 재귀적으로 펼쳐서 각 Ref를 @@ -437,7 +437,7 @@ Modifier를 합친다"는 시나리오가 `Override`의 가장 그럴듯한 실 것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴 재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게 결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch, -`Modifier.Override`, `State` 차단)엔 이 클래스별 타입 생성 작업이 +`Modifier.Overridden`, `State` 차단)엔 이 클래스별 타입 생성 작업이 전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음. **제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier diff --git a/CLAUDE.md b/CLAUDE.md index f62e722..d7e09bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1628,3 +1628,32 @@ realv 타입이 매 갱신마다 바뀔 수 있는데 '이전 핸들러'를 누 핸들러인지"가 불명확한 문제)은 Tag가 두 번째 구체 사례가 되면서 정황상 "별개 핸들러, 둘 다 `Dispatch/StoreBind.luau` 재사용"쪽에 힘이 실리지만 **아직 명시적으로 확정된 건 아님** — M2/M4 착수 전 마저 확인할 것. + +## 2026-08-08 네 번째 세션 — `Modifier.Override` → `Overridden`으로 이름 확정 + +사용자가 IDE에서 `tag-plan.md`를 보다가 "Tag가 `Added`/`Removed`처럼 +`-ed` 어미를 의도적으로 쓰는데, Modifier의 `Override`도 그냥 +`Overrided`로 하면 어떤가"라고 질문 — `-ed`/분사 어미가 "즉시 커밋되는 +뮤테이션이 아니라 이미 계산되어 반환되는 새 값"을 신호한다는 기존 관례 +(`Add`/`Remove`가 `-ed` 없이 쓰이면 뮤테이션처럼 오독될 위험이 있어 +`Added`/`Removed`로 확정했던 것과 같은 문제가 `Override`에도 그대로 +있음)에 정확히 들어맞는 좋은 관찰이었음. 다만 `Overrided`는 오기 — +`override`는 불규칙동사라 과거분사가 `overrided`가 아니라 `overridden`. +`Add`/`Remove`/`Merge`가 전부 규칙동사라 우연히 단순 `-ed` 접미만으로 +맞았던 것뿐, `Override`엔 그 규칙이 그대로 안 통함. 사용자가 이 정정에 +동의하고 확정 요청 — `Modifier.Overridden(mod1, mod2, ...)`으로 이름 +자체를 확정(더 이상 가칭 아님, 용어 정리 라운드 대상에서도 제외). + +`base/modifier-plan.md`/`base/component-composition-plan.md`/ +`base/bind-system-plan.md`/`base/tag-plan.md`(비교 문구)/ +`base/architecture.md`/`ROADMAP.md`/`research/pre-implementation-audit.md`/ +`research/documentation-content-map.md`/`.claude/README.md`/ +`.claude/question.md` 전부에서 `Override` → `Overridden`으로 기계적 +치환 + 각 문서의 "가칭"/"이름만 잠정" 표시를 "이름 확정"으로 갱신 +(`question.md`의 3순위 용어 재검토 목록에선 완전히 제거, `Peek`/ +`isState`만 그 목록에 남음). CLAUDE.md 세션 히스토리(과거 `Override` +서술)와 `archive/`는 당시 기록이라 그대로 둠 — 역사적 서술과 현재 +유효한 이름을 헷갈리지 않도록 여기 새 절로만 반영. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 순수 네이밍 +확정이라 M0 착수 우선순위나 설계 자체엔 영향 없음. diff --git a/ROADMAP.md b/ROADMAP.md index 456833e..3aca8d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -180,11 +180,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] flatten-before-dispatch(`isModifier(v)`로 배열 항목 중 Modifier만 판별해 필드 merge, 나머지는 안 건드리고 통과 — 2026-08-07 열 번째 세션 명시, `modifier-plan.md` 1번), immutable `table.clone` 체이닝 -- [ ] `Modifier.Override(mod1, mod2, ...)`(가칭, 구 `Merge`) — 필드별 raw - 덮어쓰기, 특별한 State/함수 분기 불필요(`modifier-plan.md` 9번) -- [ ] `Override`가 서브타입 관계인 서로 다른 Modifier 타입(예: `FrameModifier`/ +- [ ] `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번, 미검증 — 안 되면 일단 `Override(...: any): + (`modifier-plan.md` 9-2번, 미검증 — 안 되면 일단 `Overridden(...: any): any`로 느슨하게 열어두고 이 항목으로 되돌아올 것) - [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 From 959a519533dc07d02acac7fbe5f1f5ecda59e3b9 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 8 Aug 2026 03:57:38 +0900 Subject: [PATCH 48/62] =?UTF-8?q?decide(base):=20Handler/None/NoneHandler/?= =?UTF-8?q?Ref/PreRef/Peek/isState=20=EC=9D=B4=EB=A6=84=20=ED=99=95?= =?UTF-8?q?=EC=A0=95,=20DI=E2=86=92D=C2=B7canExecute=E2=86=92isAlive?= =?UTF-8?q?=EB=8A=94=20=EA=B3=84=EC=86=8D=20=EB=AF=B8=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "프로바이더" 개념을 Handler로 최종 확정하고 Processor/Provider/Plug를 기각한 근거를 보강. Ref/PreRef/Peek/isState/None/NoneHandler는 현재 이름 그대로 유지로 확정해 question.md 3순위 재검토 목록에서 정리. DI→D 축약안과 canExecute→isAlive 대체안은 근거는 쌓였지만 아직 미확정으로 남김. --- .claude/base/module-lifecycle-plan.md | 10 +++- .claude/question.md | 73 +++++++++++++++------------ CLAUDE.md | 42 +++++++++++++++ 3 files changed, 91 insertions(+), 34 deletions(-) diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 92ff1b4..edcb1eb 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -108,7 +108,15 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 **[해소됨]** — **`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 diff --git a/.claude/question.md b/.claude/question.md index 2f1d66d..b5d0563 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -57,7 +57,13 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 있었던 전례(`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 괜찮아요"), @@ -66,17 +72,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. - **`canExecute`(3순위, 사소함)**: 실제로 "이 핸들이 아직 살아있나" 확인인데 - 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. - **(2026-08-07 추가)** `PreRef`(children 배열 전용, Modifier/Store에 - 못 들어가는 Ref 특수화 — `base/bind-system-plan.md` "`phase` 옵션 폐기 → - 위치로 표현, `PreRef` 신설" 절)도 신규 이름이라 이 라운드에 같이 재검토 - 대상. (`CreatedRef`는 더 이상 이 목록에 없음 — 별도 이름 자체가 폐기됨, - `Ref(default)`/`PreRef(default)`를 children 배열에 직접 놓는 것으로 - 확정, 아래 "지금까지 확정된 것" 참고.) -- **`Ref`(3순위, 2026-08-06 추가)**: 정의가 "quad가 만든 instance를 얻는 - 통로"에서 "아무 사용자 값이나 담는 범용 값 박스"로 넓어져서(`base/ - bind-system-plan.md` "Ref 일반화" 절), 이름이 여전히 넓어진 의미에 - 맞는지 재검토 대상. + 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이 + 있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열 + (`isState`/`isRef`/`isPreRef`/`isModifier`/`isObserver`류 — 전부 타입 + 판별자)과 접두어가 겹쳐 "이것도 타입 체크인가" 오해를 유발할 수 있다는 + 점이 지적됨. `canExecute`는 타입이 아니라 liveness(생존 여부)를 묻는 + 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가 + 기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을 + 같이 검토할 것. - **키 기반 동적 컬렉션 재조정 프리미티브 이름(3순위, 2026-08-06 추가)**: `Keyed`는 타이핑이 어색하고 "Slot을 렌더한다"는 느낌과 안 맞는다는 사용자 피드백으로 탈락. 후보: `Render`(가장 직접적이지만 "quad엔 렌더 @@ -84,40 +87,44 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션 재조정" 절 참고. -- **`Peek`/`isState`(3순위, 사소함, 2026-08-07 다섯 번째 세션 추가)**: - 필드 읽기 접근자, State/Source 판별 predicate 두 개의 이름 — 동작은 - 전부 확정(`base/modifier-plan.md` 9번, `base/bind-system-plan.md`의 - `isState` 절), 이름만 다른 가칭들과 같이 용어 정리 라운드에서 재검토. - (`Override`는 이 목록에서 빠짐 — `Overridden`으로 확정, 2026-08-08 - 세션. `Add`/`Remove`→`Added`/`Removed`, `Merge`→`Merged`와 같은 분사형 - 네이밍 컨벤션에 맞춰 불규칙동사 `override`의 정확한 과거분사를 씀.) - **`Bound`(3순위, 사소함, 2026-08-07 일곱 번째 세션 추가)**: Observer/ Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` "이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭. -- **`None`/`NoneHandler`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: - 인라인 키/Modifier setter로 필드를 명시적으로 지우는 센티널과, 그걸 - `nil`로 바꿔 재디스패치하는 base 내장 핸들러 이름 — - `modifier-plan.md` "2-1"절/`bind-system-plan.md`의 `None` 센티널 - 절에서 동작은 확정, 이름만 다른 가칭들과 같이 재검토 대상. - **`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"류를 사용자가 검토했으나 미확정. -- **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 - "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 - 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ - 그냥 "plug" 중 뭘로 할지 아직 안 정함(개념 자체는 확정). 이 문서가 자체적으로 - "question.md에도 취합"이라고 표시해뒀던 항목이 누락돼 있어 이번에 추가. + "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`/`process`/`retract`/`isHandlable`은 업계 - 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음 - (`Ref`는 여기 포함 안 됨 — 위 3순위 목록에 이미 재검토 대상으로 있음). +- `Store`/`Source`/`Modifier`/`Ref`/`PreRef`/`Peek`/`isState`/`Handler`/ + `None`/`NoneHandler`/`process`/`retract`/`isHandlable`은 업계 선례와 + 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. ### 2. 구현 착수 직전 감사 결과 (2026-08-06 신설, M0 착수 전 확인 권장) diff --git a/CLAUDE.md b/CLAUDE.md index d7e09bd..655746a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1657,3 +1657,45 @@ realv 타입이 매 갱신마다 바뀔 수 있는데 '이전 핸들러'를 누 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 순수 네이밍 확정이라 M0 착수 우선순위나 설계 자체엔 영향 없음. + +## 2026-08-08 다섯 번째 세션 — 용어 정리 라운드 정리: `Handler`/`None`· +`NoneHandler`/`Ref`/`PreRef`/`Peek`/`isState` 이름 확정, `DI`→`D`/ +`canExecute`→`isAlive`는 계속 미정으로 재확인 + +사용자가 `.claude/question.md`의 3순위(사소함) 용어 재검토 목록을 훑으며 +한 번에 여러 개를 정리 — 전부 `.claude/question.md` "1. 용어 정리" 절과 +`base/module-lifecycle-plan.md`에 반영 완료: + +- **`Ref`/`PreRef`/`Peek`/`isState` — 전부 현재 이름 그대로 확정(더 나은 + 대안 없음).** `Ref`는 "지연 없는 확정된 값 박스"라는 정의를 재확인 — + leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도 쓰인다는 게 + 넓어진 정의에도 여전히 맞다는 근거. +- **`None`/`NoneHandler` — 확정.** `Undefined`/`Null`/`Nothing`도 검토했으나 + `Null`은 보통 "포인터가 비어있음"(0)을 뜻해 "값이 없음"이라는 의도와 + 미묘하게 안 맞는다는 이유로 기각, `None`이 나음. +- **"프로바이더" → `Handler`로 확정, 기각 이유 보강.** 이미 + `module-lifecycle-plan.md`에 [해소됨]으로 반영은 돼 있었으나 + `question.md` 목록에 stale로 남아있던 걸 정리. `Processor`는 계약 + 메소드 자체가 `process`라 이름이 겹쳐 거슬림, `Provider`는 + `canProvide`처럼 "공급한다"는 늬앙스인데 실제로는 처리/반응하는 + 쪽이라 안 맞고 React `Context.Provider`류와도 헷갈릴 수 있음, `Plug`는 + "꽂힌다"는 어감은 맞지만 "처리한다"는 의미가 빠져있음 — `Handler`가 + 계약(`isHandlable`/`priority`/`process`/`retract`) 전체를 가장 정확히 + 담는다는 결론. +- **`DI` → `D`는 아직 미확정.** 사용자가 "Declarative만 남기고 D로 + 줄이자"는 안을 제안 — Instance 전용이 아니라 quad-* 전반의 declare + 요소로 확장 가능하고, 엔진 종속 없이 재사용 가능하며, `D.FrameModifier` + 류 타입 프리픽스가 짧아야 한다는 실용적 이유까지 근거로 나쁘지 않은 + 제안이나, 한 글자 식별자의 검색성/자기설명력 트레이드오프를 문서에서 + 어떻게 보완할지가 남아 다음에 마저 결정하기로 함. +- **`canExecute` → `isAlive`도 계속 미정, 방향만 정리.** `isAlive`가 의미는 + 더 정확하지만 top-level `isX` 타입 판별자 계열(`isState`/`isRef`/ + `isPreRef`/`isModifier`/`isObserver`)과 접두어가 겹쳐 "이것도 타입 + 체크인가" 오해를 유발할 위험이 지적됨 — `canExecute`는 타입이 아니라 + liveness를 묻는 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽으로 + 사용자가 기욺, 구체 대안(`canRun` 등)은 다음에. +- `Brand`는 이번에도 다시 짚었지만 여전히 미정으로 재확인만 함. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). `DI`/`D`와 +`canExecute`/`isAlive` 두 개만 용어 정리 라운드에 계속 남음 — +`question.md` 1순위/3순위 목록 참고. From 4f3badf414a21813c0ef66e4f6bb5692d98e8fc8 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 8 Aug 2026 11:38:29 +0900 Subject: [PATCH 49/62] =?UTF-8?q?docs(base):=20=EC=BD=94=ED=8D=BC=EC=8A=A4?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EB=B0=98=EC=98=81,=20=EA=B8=B0=EA=B0=81=EB=90=9C?= =?UTF-8?q?=20=EB=8C=80=EC=95=88=20archive=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병렬 서브에이전트 4개로 base/reference/research/archive 전체를 재감사해 발견한 14건 수정 — canExecute(inst,value) 시그니처 통일, Dispatch.process가 Dispatch 체인/retractUnder와 모순되던 서술 정정, "프로바이더"→Handler 잔재 정리, Overridden 오타, :Peek 반환 타입에 None 누락, Peek/isState 미정 표기 해소 누락, slot CRUD 의미론 갭 미표기, Relate 개명 반영 누락, pre-implementation-audit.md 자기모순(이미 해소된 1-2 재언급, 옛 UI 숏핸드 이름), documentation-content-map.md 신구 이름 자기모순 및 TagService/CollectionService 재발, README.md archive 색인 누락 행, agent-mistake.md 카테고리 태그 누락. modifier-plan.md 9-1번 절에 인라인으로 남아있던 기각된 Apply-mutable 대안 두 개의 전체 경위를 archive/modifier-apply-mutable-rejected.md로 이관하고 본문은 결론+포인터로 압축 — quadnomicon 개발로그 소재 확보, 컨텍스트 비대화 방지. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 2 + .claude/archive/agent-mistake.md | 2 +- .../modifier-apply-mutable-rejected.md | 46 +++++++++++++++++++ .claude/base/architecture.md | 2 +- .claude/base/bind-system-plan.md | 14 ++++-- .claude/base/modifier-plan.md | 40 +++++----------- .claude/base/module-lifecycle-plan.md | 12 +++-- .claude/base/slot-plan.md | 7 +++ .claude/base/store-semantics.md | 3 +- .claude/base/tag-plan.md | 2 +- .claude/base/ui-shorthand-plan.md | 2 +- .claude/research/documentation-content-map.md | 6 +-- .claude/research/pre-implementation-audit.md | 15 +++--- .claude/research/tween-plan.md | 7 +-- 14 files changed, 104 insertions(+), 56 deletions(-) create mode 100644 .claude/archive/modifier-apply-mutable-rejected.md diff --git a/.claude/README.md b/.claude/README.md index ff5a135..e9fea7c 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -71,6 +71,8 @@ | `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 세션 로그의 중복 서술을 여기로 옮기고 포인터만 남김 | ## 참고 diff --git a/.claude/archive/agent-mistake.md b/.claude/archive/agent-mistake.md index 735299d..67c39ef 100644 --- a/.claude/archive/agent-mistake.md +++ b/.claude/archive/agent-mistake.md @@ -1,4 +1,4 @@ -# 에이전트 실수 기록 +# [에이전트 실수] 에이전트 실수 기록 CLAUDE.md 세션 로그 안에 흩어져 있던 "에이전트가 같은 세션 안에서 스스로 정정한 실수" 서술을 여기로 모음 — 최종 결론은 이미 각 `base/` 문서에 diff --git a/.claude/archive/modifier-apply-mutable-rejected.md b/.claude/archive/modifier-apply-mutable-rejected.md new file mode 100644 index 0000000..65a86b0 --- /dev/null +++ b/.claude/archive/modifier-apply-mutable-rejected.md @@ -0,0 +1,46 @@ +# [기각됨] Modifier `Apply`/setter를 mutable로 바꾸는 방안 (전체·절충안 둘 다) + +**상태**: 후보였다가 채택 안 됨(확정한 적 없이 검토 후 기각) — `base/ +modifier-plan.md` 9-1번 절에서 이 판단의 결론(판단 기준 자체는 "동질적/ +이질적"이 아니라 "계산 의존성 유무")만 남기고 아래 전체 경위는 이 문서로 +옮김. `batch-rejected.md`/`context-rejected.md`와 같은 카테고리 — +`quadnomicon` 소재 후보. + +## 배경 + +2026-08-07 다섯 번째 세션 후속. `Apply` 체이닝이 호출마다 clone을 만들기 +때문에, 항목 수천 개짜리 리스트 UI처럼 무거운 Modifier를 대량으로 +재생성하는 상황에서 이 clone 비용이 누적되는 게 아닌지 사용자가 우려 — +대안으로 (a) `Apply`/setter를 아예 mutable로 바꾸는 방안, (b) `Overridden`를 +"여러 값을 합칠 특수 상황"이 아니라 "성능 최적화 수단"으로 승격하는 방안을 +검토했음(이 문서는 (a)와 그 절충안만 다룸 — (b)는 기각되지 않고 "계산 +의존성 유무" 판단 기준으로 정리되어 `modifier-plan.md` 본문에 그대로 남음). + +## (a) `Apply`를 mutable로 바꾸는 방안 — 기각 + +3번 절에서 immutable+clone을 확정한 이유가 정확히 "같은 modifier +레퍼런스를 공유하는 형제 서브트리가 mutate로 오염되는 것"을 막기 +위해서였음 — 이건 특정 세션 판단이 아니라 2026-08-04부터 계속 지켜온 +하드 제약. `Apply`/setter가 mutable이면 여러 컴포넌트가 참조하는 공유 +테마 상수 하나에 어느 한쪽이 체이닝만 해도 다른 쪽까지 같이 바뀌는 +클래스의 버그가 그대로 돌아옴 — clone 비용 절감이 이 안전성보다 +우선순위가 높다고 볼 근거가 없어 기각. (단, `table.clone`은 Luau native +shallow-copy라 Modifier 필드 수(한 자리~여남은 개) 기준 개별 clone 비용 +자체는 이미 3번 절에서 무시 가능하다고 판단됨 — 이번에 새로 문제 삼는 건 +"한 번 비용의 크기"가 아니라 "체인 길이 × 인스턴스 수로 누적되는 clone +*횟수*"라는 별개 축.) + +## (a-1) 절충안 — "`Apply` 진입 시 한 번만 clone하고 그 안에서는 mutable로" — 검토했으나 기각 + +clone 횟수를 체인 길이만큼이 아니라 `Apply` 호출당 1번으로 줄이자는 +아이디어(`Apply` 경계에서만 복사, 내부 setter들은 그 복사본을 그대로 +mutate). + +**기각 이유**: 이렇게 해도 버그 클래스 자체가 안 없어짐 — `Apply`를 +거치지 않고 setter를 직접 호출하는 흔한 경로(`mod:FontSize(...)`처럼 +체이닝 자체가 아니라 단발 호출)는 여전히 mutable이라, 공유 레퍼런스에 +대고 단발 setter 하나만 불러도(예: 서브트리 어딘가에서 폰트 두께만 살짝 +바꾸는 경우) 그대로 오염됨 — "`Apply` 안에서는 안전, 밖에서는 안 안전"처럼 +**어디서 터지느냐만 달라질 뿐 문제 자체는 그대로 남는 비일관적인 +절충**이라 실익이 없음. 전부 clone하는 지금 방식이 버그 클래스를 균일하게 +없애는 유일한 방법 — 확정 유지. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 0cdca55..a157fe7 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -35,7 +35,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/ `Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유. 네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리 - 복잡도가 너무 올라간다고 판단 — 당장은 TagService 그대로 사용. **대신 + 복잡도가 너무 올라간다고 판단 — 당장은 `CollectionService` 그대로 사용. **대신 Ref가 도입됨** — 단 Ref의 용도는 "id로 조회"가 아니라 "외부에서 이미 관리되고 있는 instance를 quad로 점진적으로 마이그레이션/래핑하기 위해 직접 참조를 얻는 것"(`base/bind-system-plan.md`의 Ref 절 참고) — 둘을 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 453ad95..46eaf18 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -220,10 +220,13 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 - `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+ `priority`), 부작용 없음. - `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 → - 이전에 이 키를 담당하던 핸들러와 다르면 이전 핸들러의 `retract` 호출 → - 새로 매치된 핸들러의 `.process` 호출. **재귀 재디스패치(Tween/일반 - store-bind/`NoneHandler`)는 전부 이 `Dispatch.process`를 다시 부르는 - 것** — 원래 있던 재귀 관례 그대로, 새로 바뀐 것 없음. + 매치된 핸들러를 `(inst,k)` 체인 꼬리에 push → 그 핸들러의 `.process` + 호출. **"이전 핸들러와 다르면 retract"라는 diff는 `Dispatch.process` + 자신의 일이 아님** — 재귀/래핑 핸들러(Tween/일반 store-bind/ + `NoneHandler`)가 재-dispatch 전에 스스로 `Dispatch.retractUnder(inst, + k, self, newV)`를 먼저 불러 자기 밑을 정리하는 책임을 짐(정확한 + 메커니즘·기각된 대안은 아래 "Dispatch 체인" 절 참고 — 전역 소유자 + 슬롯 하나로 diff하는 안은 래핑 핸들러에서 깨져서 기각됨). - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ @@ -1448,7 +1451,8 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사 - `base/store-semantics.md`에 있던 "`isInit=false`면 허용, `isInit=true`+ 생존확인 거짓이면 불허" 분기 초안은 폐기. state-invalidate 리스너 클로저도 `base/lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute - predicate)로 등록하면, 발화 시 `canExecute(handle)` 하나만 확인하고 거짓이면 + predicate)로 등록하면, 발화 시 `canExecute(inst, value)`(2026-08-08 세션 + 최종 시그니처) 하나만 확인하고 거짓이면 그냥 no-op — `isInit` 분기라는 별도 개념 자체가 불필요(사용자 확정: "canExecute 하나로 통일"). diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 756fa30..7093350 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -365,33 +365,14 @@ Modifier가 들고 있는 모든 필드는 항상 "그 시점에 이미 완전 **동기**: `Apply` 체이닝이 호출마다 clone을 만들기 때문에, 항목 수천 개짜리 리스트 UI처럼 무거운 Modifier를 대량으로 재생성하는 상황에서 이 clone 비용이 누적되는 게 아닌지 사용자가 우려 — 대안으로 (a) `Apply`/setter를 -아예 mutable로 바꾸는 방안, (b) `Overridden`를 "여러 값을 합칠 특수 상황"이 -아니라 "성능 최적화 수단"으로 승격하는 방안을 검토. +아예 mutable로 바꾸는 방안(과 그 절충안), (b) `Overridden`를 "여러 값을 +합칠 특수 상황"이 아니라 "성능 최적화 수단"으로 승격하는 방안을 검토. -**(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하는 지금 방식이 버그 클래스를 균일하게 없애는 유일한 방법 — -확정. +**(a) `Apply`/setter를 mutable로 바꾸는 방안(및 "`Apply` 경계에서만 clone" +절충안) — 둘 다 검토 후 기각.** 3번 절의 immutable+clone 하드 제약(형제 +서브트리 오염 방지)이 clone 비용 절감보다 우선순위가 높다는 결론, 절충안도 +"어디서 터지느냐만 달라질 뿐 문제 자체는 남는" 비일관적 타협이라 기각 — +전체 경위·반박 논리는 `archive/modifier-apply-mutable-rejected.md` 참고. **(b) 판단 기준 — "동질적 vs 이질적 프로퍼티"가 아니라 "필드 간 계산 의존성 유무"로 명시.** 사용자가 처음엔 "동질적(폰트 굵기 보정처럼 연관된 @@ -461,7 +442,7 @@ Modifier 값을 변수/모듈 상수로 만들어 재사용하는, 기존에도 시점에 실 테스트 결과에 따라 다시 좁히는 걸 목표로 로드맵에 남김 (`ROADMAP.md` M7). -**`:Peek<>(key): T | State | nil`** — Modifier 필드를 확정하지 +**`:Peek<>(key): T | State | None | nil`** — Modifier 필드를 확정하지 않고 그대로 읽는 접근자. 이름을 `Get`이 아니라 `Peek`로 정한 이유: 이 프로젝트 전역에서 `State:Get()`은 "확정한다"(pull + recompute + 최종값 반환)는 의미로 이미 자리잡았는데, Modifier의 읽기는 정반대(들고 있는 @@ -508,5 +489,6 @@ Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSou - **[해소됨]** `Overridden` 이름 — 2026-08-08 세션에서 확정(`Add`/`Remove` →`Added`/`Removed`, `Merge`→`Merged`와 같은 분사형 네이밍 컨벤션에 맞춰 불규칙동사 `override`의 정확한 과거분사를 씀, `Overrided`는 오기). - `Peek`/`isState` — 동작은 위 9번 절에서 확정, 정확한 이름은 다른 - 가칭들과 마찬가지로 `.claude/question.md`의 용어 정리 라운드까지 잠정. + `Peek`/`isState` — 동작은 위 9번 절에서 확정, 이름도 2026-08-08 다섯 + 번째 세션(`.claude/question.md` 용어 정리 라운드)에서 더 나은 대안 없어 + 현재 이름 그대로 최종 확정됨. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index edcb1eb..ce940ae 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -1,4 +1,4 @@ -# 모듈 라이프사이클 — 프로바이더 패턴, bind/store는 누가 구현하는가 (base로 승격됨) +# 모듈 라이프사이클 — Handler 패턴, bind/store는 누가 구현하는가 (base로 승격됨) **상태**: base — "누가 store를 구현하는가"까지 포함해 전부 확정되어 `research/`에서 승격됨(`base/architecture.md`의 "구현 착수: 소스 트리 구조 @@ -14,10 +14,12 @@ bind는 누가 어떻게 구현" / "스토어는 누가 구현해…" 절. 확 Slot과 맞물려서 잘 생각해서 구현해야 하는 부분. **기울어진 방향**: mount가 처리하는 게 맞아 보이지만, 그러면 확장성이 있을지가 문제. 결론: **표준 구현체는 인터페이스만 두고, 실제 구현은 `quad-roblox` 같은 백엔드 서브패키지가 해당 -인터페이스를 구현**. 런타임에 프로바이더로 Roblox를 주입받는 방향(반대로 -"프로바이더로 base를 받는" 게 아니라) — 이유: 여긴 가상돔이 없어서, base -쪽이 "누가 실제로 그려주는지" 모르는 채로 있다가 Roblox 프로바이더를 주입받는 -모양이 더 자연스러워 보임. +인터페이스를 구현**. 런타임에 Handler로 Roblox를 주입받는 방향(반대로 +"Handler로 base를 받는" 게 아니라) — 이유: 여긴 가상돔이 없어서, base +쪽이 "누가 실제로 그려주는지" 모르는 채로 있다가 Roblox Handler를 주입받는 +모양이 더 자연스러워 보임. (이름 자체는 이후 "Handler"로 확정 — +`base/bind-system-plan.md`의 핸들러 계약 절 참고, 이 문서는 여전히 +초안 당시 표현인 "프로바이더"로 쓰여 있던 걸 정정.) ## pluggable 플러그 초기화는 누구 몫인가 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index d3092fc..f0a969a 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -134,3 +134,10 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데 - **여러 Slot이 형제로 섞일 때 순서 보장**은 아직 열려있음(위 "여러 Slot이 섞일 때 순서 보장" 절 참고) — Roblox 단일 백엔드로는 급하지 않음, Slot 코어 로직 구현 시점에 재검토. +- **`add`/`remove`/`clear` CRUD 의미론 자체가 아직 정의 안 됨** — 위 + "개념" 절이 이 세 연산을 뮤터블 메타 배열에 지원되는 것처럼 나열만 + 하고 정확한 동작(예: `add`가 위치를 지정하는지, `remove`가 값 동등성 + 기준인지 참조 기준인지, `clear`가 재마운트 가능한 자리를 남기는지)은 + 정의돼 있지 않음. `research/pre-implementation-audit.md`가 이미 지적한 + 갭이고, 2026-08-07 아홉 번째 세션에서 사용자가 직접 다루기로 보류함 — + Slot 코어 로직 구현 착수 전 반드시 확정 필요. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index c493e3c..15d4cb1 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -31,7 +31,8 @@ purity-and-effects-plan.md`와 연결됨). 어떻게 되는가 — 별도 메커니즘을 새로 만들 필요 없이, `base/ lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute predicate)을 state- invalidate 리스너 클로저 등록에도 그대로 재사용하면 됨: 발화 시 -`canExecute(handle)` 하나만 확인, 거짓이면 no-op. 한때 검토했던 `isInit=false`면 +`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 온톨로지" 절 참고. diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index 02478ea..eb01544 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -22,7 +22,7 @@ tag:Contains(name): boolean -- 멤버십 확인 tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴) Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의 Overridden(필드 단위 덮어쓰기, 손실 있음)와 - 다른 연산이라 이름도 다름 — Override는 + 다른 연산이라 이름도 다름 — Overridden은 "이미 계산된 걸 합침", Merged는 "집합을 합침" ``` diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 67fd295..cee3d1c 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -121,7 +121,7 @@ base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저 ## 남은 열린 질문 (단순화 후보, 사소함) -- Corner/PaddingAll/Scale 3개 거의 동일한 형태의 Handler를 각각 만들지, +- UICorner/UIPadding/UIScale 3개 거의 동일한 형태의 Handler를 각각 만들지, `{key -> {ChildClassName, ChildDefaultName, Property, wrap=fn}}` 룩업 테이블로 구동되는 단일 `Handlers/InstanceShorthand.luau`로 통합할지 — `research/pre-implementation-audit.md` 3-2번 참고, 강제 사항 아님, diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 7379632..af1986c 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -45,7 +45,7 @@ ### architecture.md - 초심자: DOMless 즉시 Instance 생성 모델 / 특수 바인드 키 / Ref 기본 개념 / modifier 기본 사용법(스타일링) / Store·State·Source 온톨로지 핵심 동작 / quad-base·quad-roblox 패키지 구조 존재 사실 -- api: Class 함수형+`:` 체이닝 예외 규칙 / store 바인드=전체 교체 의미론 / Tag/retract(TagService 기반) / modifier 병합 우선순위 규칙(→심화: CSS cascade 회피 근거) / PropertyChangedSignal이 pluggable 핸들러로 구현 / Source·State·Store 타입 정의 +- 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 설계) @@ -92,9 +92,9 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 - skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 ### store-semantics.md / tween-plan.md / ui-shorthand-plan.md -- 초심자: Store 생성+`myStore.key = value` 문법 / `store.key`로 State 얻기 개념 / Tween 기본 바인드 키+취소 기본 동작 / UI 숏핸드 인라인 키 기본 예시(`Frame { PaddingAllOffset = 50 }`) +- 초심자: Store 생성+`myStore.key = value` 문법 / `store.key`로 State 얻기 개념 / Tween 기본 바인드 키+취소 기본 동작 / UI 숏핸드 인라인 키 기본 예시(`Frame { UIPaddingOffset = 50 }`) - api: `:With`+`:Compute` 시그니처(→심화) / `source:Emit()` 존재+"Get() 결과 캐시 금지" 캐비엇(버그 유발 포인트라 api에도 명시 가치 있음, →심화; 2026-08-06 후속 세션에서 `Store:Emit(key)`→`source:Emit()`로 호출부 변경, `store-semantics.md` 참고) / Tween 핸들러가 Instance 직접 받음(Ref 불필요) / retract는 Destroy 시 호출 안 됨(→심화) / UI 숏핸드 키 목록 레퍼런스 표 / Modifier와 순수 인라인 키 동등성 -- 심화: Source·Store·State·Observer 온톨로지(독립 프리미티브 vs 파생 데이터 원칙, 생성자 모양 근거) / `Emit`이 Source 전용인 이유(디버깅 그래프 무결성) / `Store`의 T가 Modifier 불가인 이유 / Tween을 반응 그래프 밖 특수 bind key로 둔 이유(Fusion 반면교사) / RoundSize 포팅 불필요 vs Corner/PaddingAll/Scale 필요 이유 / "작고 opt-in 아닌 편의 기능은 코어 포함" 원칙 +- 심화: Source·Store·State·Observer 온톨로지(독립 프리미티브 vs 파생 데이터 원칙, 생성자 모양 근거) / `Emit`이 Source 전용인 이유(디버깅 그래프 무결성) / `Store`의 T가 Modifier 불가인 이유 / Tween을 반응 그래프 밖 특수 bind key로 둔 이유(Fusion 반면교사) / RoundSize 포팅 불필요 vs UICorner/UIPadding/UIScale 필요 이유 / "작고 opt-in 아닌 편의 기능은 코어 포함" 원칙 - 열린 질문(문서화 보류): tween-plan.md의 오버라이드/삭제후재시작/끝점이동 옵션 키 이름 미정 / ui-shorthand의 RoundSize 완전 드롭 여부 - skip: 세션 정정 이력, v1 소스 조사 경위 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index b1e7633..c2558af 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -518,12 +518,12 @@ M11 착수 시. 쉬워진다든가)가 있다면 한 줄 추가하고, 없다면 "그냥 클로저 업밸류를 쓰라"는 문서화 패턴으로 대체해 API 표면 자체를 줄이는 걸 검토. -### 3-2. Corner/PaddingAll/Scale 개별 Handler 대신 데이터 테이블 구동 단일 제네릭 Handler +### 3-2. UICorner/UIPadding/UIScale 개별 Handler 대신 데이터 테이블 구동 단일 제네릭 Handler **위치**: `base/ui-shorthand-plan.md` "메커니즘 — 새 아키텍처 개념 불필요" 절. -**문제**: 문서는 "Corner/PaddingAll/Scale 같은 특수 키를 인식하는 +**문제**: 문서는 "UICorner/UIPadding/UIScale 같은 특수 키를 인식하는 Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 값 하나 → 고정 이름 자식 찾기/생성 → 프로퍼티 세팅)의 Handler를 각각 만드는 그림이다. 문서 자체가 "앞으로 비슷한 제안이 오면 이 선례를 따르라"고 @@ -531,8 +531,8 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 선형으로 늘어나는 구조. **제안**: `{key -> {ChildClassName, ChildDefaultName, Property, wrap=fn}}` -형태의 룩업 테이블 하나로 구동되는 단일 `Handlers/InstanceShorthand.luau` -로 통합하는 안을 검토. 새 shorthand 키 추가가 "테이블에 항목 하나 추가"로 +형태의 룩업 테이블 하나로 구동되는 단일 `Handlers/InstanceShorthand.luau`로 +통합하는 안을 검토. 새 shorthand 키 추가가 "테이블에 항목 하나 추가"로 끝나 M10 이후 유지보수 비용이 줄어듦. 강제 사항 아님, 구현 시점에 결정할 정도의 사소한 개선 후보. @@ -595,8 +595,11 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 반영 완료. 1-10(`store.key` 레코드 필드 타이핑)을 M0로 앞당기는 것 검토, 1-11(Modifier `__index` 트릭)도 비용이 낮으니 M0 후보로 포함 검토는 계속 열려있음. -- **M2(Dispatch) 착수 전**: 1-2, 1-3, 1-4를 한 번에 확정(전부 base - dispatch 엔진의 에러/상태관리 규칙이라 같이 결정하는 게 효율적). +- **M2(Dispatch) 착수 전**: 1-3, 1-4를 한 번에 확정(전부 base dispatch + 엔진의 에러/상태관리 규칙이라 같이 결정하는 게 효율적). 1-2는 2026-08-08 + 세 번째 세션에 Dispatch 체인+`retractUnder`로 이미 해소됨(위 1-2번 항목 + 참고) — 남은 건 M2 스파이크에서 다단 체인 케이스가 실제로 맞게 + 동작하는지 실측하는 것뿐. - **M2/M3 착수 전**: 1-6(canExecute 실제 구현) 실측(1-9는 반영 완료, 위 항목 참고). - **나머지**: 해당 마일스톤 착수 시점에 이 문서를 다시 열어 관련 항목만 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index c1fb808..9db5085 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -69,9 +69,10 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 라이브러리가 강제하지 않고, `[Tween(key, tweenData, {onOverride=...})]`처럼 키 설정으로 사용자가 고를 수 있게 열어둠 — `retract(inst, k, v)`가 이전 값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에 -생성한 실제 Tween 객체"는 `base/bind-system-plan.md`가 말하는 base 제공 -범용 유틸(`inst`를 키로 하는 weak-keyed per-instance 상태 저장소)에 담아두면 -됨. **GC 확인(2026-08-07 여섯 번째 세션, 사용자 제안 검증)**: 이 저장소는 +생성한 실제 Tween 객체"는 `base/relate-plan.md`가 정의하는 `Relate`(`inst`를 +weak 키로 하는 범용 릴레이션 프리미티브, 옛 가칭 `base.perInstanceState`를 +대체)에 담아두면 됨. **GC 확인(2026-08-07 여섯 번째 세션, 사용자 제안 +검증)**: 이 저장소는 `inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된 구조라, `inst`가 죽으면 그 안에 담긴 Tween 인스턴스 릴레이션도 별도 정리 로직 없이 같이 GC됨 — `base/bind-system-plan.md`의 "핸들러 내부 From 8169b90a0ea1cf483e724f4b969a57e412c8a7b5 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 16:00:19 +0900 Subject: [PATCH 50/62] =?UTF-8?q?decide(base):=20canBound=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=ED=99=95=EC=A0=95,=20Compute=20previous=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4/=EC=8A=A4=EC=BD=94=ED=95=91=20=EB=AA=85?= =?UTF-8?q?=ED=99=95=ED=99=94,=20Modifier=20=ED=95=B8=EB=93=A4=EB=9F=AC?= =?UTF-8?q?=EA=B0=92=C2=B7State=20UB=E2=86=92error=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound 플래그 → canBound(handle) 탑레벨 함수로 확정(canExecute와 같은 결) - :Compute의 previous 인자 오버엔지니어링 의심 기각, 결과 노드 귀속으로 스코핑 명확화(self.Cache 안 씀 — 팬아웃 충돌 회피) - Modifier 필드의 Ref/PreRef/Observer/Effect/Slot/Modifier 값: UB → isX predicate 기반 즉시 error - State/Source가 Modifier를 값으로 담는 것(State)도 동일하게 error로 통일, Slot 등은 계속 허용 - Tween initValue/useTween 논의 신설(미확정) - pre-implementation-audit.md 3-1/2-2/문서모순 절 해소 반영, question.md/ROADMAP.md 동기화 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/base/bind-system-plan.md | 96 ++++++++++++++++---- .claude/base/effect-plan.md | 3 +- .claude/base/modifier-plan.md | 87 ++++++++++++++---- .claude/base/store-semantics.md | 15 +-- .claude/question.md | 18 ++-- .claude/research/pre-implementation-audit.md | 28 ++++++ .claude/research/tween-plan.md | 34 +++++++ CLAUDE.md | 86 ++++++++++++++++++ ROADMAP.md | 4 +- 9 files changed, 320 insertions(+), 51 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 46eaf18..f67ecae 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -697,8 +697,9 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store - Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 - 없음 — `State`를 UB로 보고 타입으로 막기로 한 것과 정확히 - 같은 원칙의 재적용. + 없음 — `State`를 막기로 한 것(`modifier-plan.md` 7번, + 2026-08-09 세션부터 `isModifier` 기반 명시적 error)과 정확히 같은 + 원칙의 재적용. - **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 `Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 @@ -989,6 +990,45 @@ stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. "Ref로 한 번 얻어서 수동으로 Parent만 하고 끝"처럼 능동적 관측 경로가 안 남아있으면, 이 최적화는 그냥 조용히 작동을 멈춘다. +**[2026-08-09 세션] 오버엔지니어링 의심 재검토 — 기각, 현재 설계 +유지.** `research/pre-implementation-audit.md` 3-1이 "클로저 업밸류로 +이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 것에 대한 사용자 +반박: 클로저 업밸류 대안은 실제로 다음처럼 즉시실행함수(IIFE)로 감싸 +업밸류를 준비해야 함 — + +```lua +local computeFn = (function() + local prev + return function(self) + -- prev를 읽고 새 값을 계산, prev 갱신 + prev = ... + return prev + end +end)() +someSource:Compute(computeFn) +``` + +이 준비 코드 자체가 이미 별도 `previous` 인자 하나보다 무겁고 번거로움 +— "재사용하고 싶으면 그냥 캐시된 값을 바로 넘겨주면 되는" 게 더 +단순하다는 게 사용자 논거. 반대로 `previous`가 없으면 `fn`은 매 호출마다 +새 인스턴스를 만들어야 해서(예: `LocalizationTable.new()`) lazy든 +아니든 재계산이 일어날 때마다 항상 비싼 재생성이 발생 — `previous`가 +막으려는 문제는 실재함. **`pre-implementation-audit.md` 3-1 해소 — +현재 `fn(self, previous)` 설계 그대로 유지, API 표면을 줄이지 않음.** + +**스코핑 명확화(이번 세션에 확인, 새 결정 아님) — `previous`는 `self` +(입력)가 아니라 "이 `:Compute` 호출 하나가 만들어낸 결과 State 노드" +자신에 귀속된다.** State가 `:With`/`:Compute` 호출마다 새 노드를 +만든다는 건 이미 확정된 온톨로지(아래 "왜 State 체인을 Modifier처럼 +플래튼하지 않는가" 절)라, `previous`도 그 새 노드의 내부 캐시 슬롯일 +뿐 `self`에 얹히는 게 아님 — 같은 `self`에서 여러 `:Compute`가 갈라지는 +팬아웃(`c1 = w:Compute(g1)`, `c2 = w:Compute(g2)`)이 있어도 `g1`/`g2` +각자의 `previous`는 각자의 결과 노드에 독립적으로 저장되므로 서로 안 +섞임 — 새로 결정할 것 없이 기존 "노드별 캐시" 원칙의 당연한 귀결. +(참고: `self.Cache`처럼 `self` — 즉 입력 — 에 캐시를 얹는 모양은 이 +스코핑과 안 맞아 채택하지 않음 — 팬아웃 시 여러 소비자가 같은 +`self.Cache` 슬롯을 공유해 덮어쓰는 충돌이 생기기 때문.) + ### `state:Observer(fn)` — 값을 안 실어주는 구독, children 배열에 직접 놓는 leaf 값 **결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` @@ -1169,7 +1209,7 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능. -### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `Bound` 플래그로 즉시 에러 (2026-08-07 일곱 번째 세션) +### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정) **규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 @@ -1181,29 +1221,44 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 **UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 -바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈: +바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈. + +**이름 확정 — `canBound(handle): boolean`, `canExecute`와 같은 결의 +탑레벨 함수(2026-08-09 세션, 가칭 `Bound` 필드를 직접 노출하는 대신).** +`canExecute(inst, value)`가 "지금 살아있어서 실행돼도 되는가"를 묻는 +탑레벨 predicate인 것과 똑같이, "아직 어느 경로로도 안 묶였는가"도 +raw 필드(`self.Bound`)를 직접 보여주지 않고 같은 스타일의 탑레벨 +함수로 감싼다 — Observer/Effect 둘 다 쓰는 범용 predicate라 특정 +프리미티브 하나의 전용 소유물이 아니므로(`store-semantics.md`의 +네이밍 케이싱 기준: "이 이름이 특정 프리미티브 타입 하나의 전용 +소유물인가?"에 아니오라 소문자 탑레벨이 맞음, `architecture.md` +"코드 스타일 — 네이밍 케이싱" 절과 같은 기준): ```lua -- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인 -if self.Bound then +if not canBound(self) then error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음") end -self.Bound = true +-- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐) ``` -- **`Bound`는 가칭** — 용어 정리 라운드에서 최종 이름 재검토 대상 - (`.claude/question.md`에 반영). -- 이 플래그는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 - 표시 — 두 진입점이 똑같이 확인/설정하므로 순서와 무관하게 대칭적으로 - 막힘. +- `canBound(handle)`은 "이 핸들이 아직 어느 경로로도 안 묶였으면 + `true`, 이미 한 번 묶였으면 `false`"를 답하는 순수 predicate — 내부 + 구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만, + 공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만 + 바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는 + 안 바뀜. +- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 + 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 + 대칭적으로 막힘. - **`:Unsubscribe()`는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 - 뜻으로 통일** — `Bound`가 어느 경로로 세워졌는지와 무관하게, - `:Unsubscribe()` 한 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 - 강참조 등록이든)을 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 - 자동(리프) 케이스에도 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 - 별개로, "단일 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. + 뜻으로 통일** — 바인딩이 어느 경로로 세워졌든, `:Unsubscribe()` 한 + 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 강참조 등록이든)을 + 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 자동(리프) 케이스에도 + 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 별개로, "단일 + 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. - **Effect도 동일 규칙 적용** — 내부적으로 Observer를 조합하는 경우든 - `state` 없는 경우든 같은 `Bound` 게이트를 그대로 재사용 + `state` 없는 경우든 같은 `canBound` 게이트를 그대로 재사용 (`base/effect-plan.md`). 이전에 그 문서에 적어뒀던 "leaf 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 하는 @@ -1761,6 +1816,13 @@ Brand.get(x) == PreRefTag`) — `isState`처럼 집합 멤버십이 아님, 즉 서로 배타적인 형제 브랜드. `isModifier`도 같은 단순 항등 (`Brand.get(x) == ModifierTag`). +**같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — +`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 +predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX` +wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 +"핸들러 계층 값이 필드로 들어오면 즉시 error" 절이 필요로 해서 이번에 +같이 적음. + **`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** `Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 4ccecfc..7427cbc 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -109,7 +109,8 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니 동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 - 규칙과 `Bound` 플래그 기반 즉시-에러 메커니즘은 + 규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` + 플래그, 2026-08-09 세션에서 이름 확정)은 `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. leaf 부착 **후** `:Unsubscribe()`로 조기 해제하는 것(위 "Observer의 `:Unsubscribe()`는 자동 케이스에도 동일하게 씀" 패턴)은 여전히 정상 — diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 7093350..f78320f 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -184,14 +184,34 @@ Roblox API에 전혀 의존 안 하는 순수 Lua 테이블 조작이라, "base **Modifier의 체이닝 엔진 자체는 quad-base에 완결된 구현으로 그대로 존재해도 됨** — 주입할 엔진별 구현이 애초에 없음. -**Modifier는 핸들러 계층을 모름 — 순수 데이터 merge 레이어.** Modifier는 -`process`/`retract`/`isHandlable` 핸들러 레지스트리보다 아래층(위 "1. -런타임 pluggable 핸들러 아님" 참고)이라, Ref/Slot 같은 핸들러 계층 -개념을 아예 몰라도 됨 — 필드에 Ref나 Slot 같은 값이 들어가도 Modifier -입장에선 그냥 flatten해서 최종 props 테이블에 얹을 뿐. 권장하는 사용법은 -아니지만(Modifier는 스타일링 데이터 지향이니), 막을 이유도 없음 — 방어 -로직 없는 UB로 남겨둠(오늘 세션 내내 반복된 "드문 오용까지 방어하려고 -구조 복잡하게 안 만든다" 원칙과 동일). +**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`가 참인 + 값은 이 체크를 그냥 통과함). +- **7번 절(`State` UB)과의 비대칭이 이걸로 줄어듦** — + `pre-implementation-audit.md`가 지적했던 "같은 문서 안에서 한쪽은 + 방어(타입 차단 시도), 한쪽은 무방비 UB"라는 비일관성이, 이제 둘 다 + "적극적으로 막는다"는 같은 방향으로 정리됨(메커니즘은 여전히 다름 — + 하나는 타입 레벨 차단 시도+실패 시 UB 폴백, 하나는 런타임 `error` — + 이 차이 자체는 남지만 "막을 가치가 있는가"라는 판단은 통일됨). ### 4-1. 필드가 State일 수도 있음 — Setter가 State/plain 여부로 분기 @@ -248,17 +268,50 @@ Modifier가 immutable해야 하는 이유(변환마다 clone)와 State가 이미 명시적으로 쓰는 구조라 "암묵적 분기"가 애초에 존재하지 않음 — 새로 결정할 것 없음. -### 7. State가 Modifier를 값으로 담는 것은 UB — 타입으로 막을 것 (2026-08-04, 로드맵 인수인계 라운드) +### 7. State/Source가 Modifier를 값으로 담는 것 — 명시적 error로 확정 (2026-08-04 신설, 2026-08-09 세션 정정) -Modifier "필드"가 State일 수 있는 것(4-1번)과는 별개로, **State 자체의 -value가 Modifier인 경우**(예: `someState:With(fn)`이 Modifier를 반환)는 -지원 대상이 아님 — Modifier는 "flatten해서 한 번 적용"이 전제인 정적 값인데, -State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어 매번 재-flatten이 -필요해지고, 이는 "정적 merge" 확정(1번)과 정면으로 충돌함 — **사용자 +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가 있으면 그건 끔찍히 힘들꺼야... 타입 상 받지 -못하게 만들어야 할 수도 있고"). **UB로 확정, 가능하면 타입 시스템으로 -아예 못 넣게 막을 것**(`State` 같은 조합을 타입 정의 단계에서 -거부) — 런타임 가드가 아니라 타입 차단을 우선 검토. +못하게 만들어야 할 수도 있고"). + +**[정정, 2026-08-09 세션] "UB, 가능하면 타입 차단"에서 "명시적 +`error`로 확정"으로 전환** — 위 "핸들러 계층 값이 필드로 들어오면 +즉시 error" 절(Ref/PreRef/Observer/Effect/Slot/Modifier가 Modifier +*필드*로 들어오는 걸 막은 것)과 같은 방향으로 통일: `isModifier` +predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 +막는다.** 타입 차단(`State` 같은 조합을 타입 정의 단계에서 +거부)은 여전히 되면 좋은 보너스로 계속 시도해볼 수 있지만 +(`research/pre-implementation-audit.md` 2-2 — Luau에서 실제로 가능한지 +미검증), **더 이상 유일한 방어선이 아님** — 타입이 뚫려도 런타임 +`error`가 항상 잡아준다. + +- **적용 지점**: "어떤 값이 Source/State의 현재 값으로 확정되는 모든 + 지점" — `Source:Set(value)` 호출 시, `Store({defaults})` 생성 시 + 각 `defaults` 키를 `Source(v)`로 만드는 시점, 그리고 State의 + `:Compute(fn)` 결과를 캐시로 저장하기 직전(`fn`이 반환한 값이 + `isModifier`면 캐싱 전에 `error`). 새 체크 지점을 여러 곳에 흩는 게 + 아니라, "값이 State/Source의 값으로 확정되는" 이미 존재하는 몇 안 + 되는 지점에 `isModifier` 검사 한 줄씩 얹는 것뿐. +- **Slot/Tag/Attribute/Tween 등 다른 핸들러 계층 값은 여전히 아무 + 문제 없이 State/Source에 담길 수 있음 — Modifier만의 예외임을 + 명확히.** (사용자 확인: "slot은 당연히 가능함, retract도 되는 애고 + 런타임 값이라") 이 값들은 전부 정상적으로 `process`/`retract` + 재귀 경로(store-bind 재실행 모델, "확정된 디스패치 모델" 절)를 타는 + 진짜 런타임 dispatch 참가자라, State/Source 값으로 담겨 바뀌어도 + 기존 재귀 재-dispatch 메커니즘이 그대로 처리해줌 — 새로 막을 이유가 + 없음. Modifier만 유독 문제인 건 Modifier가 애초에 dispatch 경로를 + 아예 안 타는 유일한 존재(1번 절)라서, State/Source에 담기는 순간 + "재귀 재-dispatch로 처리"할 대상 자체가 없어지기 때문 — 이 구분이 + 왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. +- **`Store`의 `T`는 Modifier가 될 수 없음(`base/store-semantics.md` + "따름정리" 절)도 이 결정을 그대로 물려받음** — Source가 State를 + 구조적으로 만족하므로 별도로 다시 논증할 필요 없이 동일하게 적용됨. ### 8. `:Apply(factory)` — 팩토리 함수 체이닝 지원 (2026-08-07) diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 15d4cb1..66d3407 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -285,16 +285,19 @@ mutation에 정식 신호를 붙여주는 것뿐. **따름정리 — `Store`의 `T`는 Modifier가 될 수 없음.** Modifier는 정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고 -(`base/modifier-plan.md`), `State`가 UB로 확정된 것도 같은 -이유(`modifier-plan.md` 7번) — Store/State/dispatch 경로엔 애초에 -Modifier용 processor가 없음. **[2026-08-06 후속 세션 추가]** Source가 -State를 구조적으로 만족하게 되면서 이 UB는 `Source`(Store를 +(`base/modifier-plan.md`) — Store/State/dispatch 경로엔 애초에 +Modifier용 processor가 없음. **[정정, 2026-08-09 세션]** `State` +조합은 "UB, 가능하면 타입 차단"이 아니라 **명시적 `error`로 확정** +(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source:Set()`/ +Store 생성 시 eager `Source(default)`/State의 `:Compute` 결과 캐싱 +지점에서 확인해 런타임에 직접 막음, 타입 차단은 되면 좋은 보너스일 +뿐 유일한 방어선이 아님. **[2026-08-06 후속 세션 추가]** Source가 +State를 구조적으로 만족하게 되면서 이 제약은 `Source`(Store를 거치지 않는 독립 `Source(someModifier)`)에도 동일하게 적용됨을 명시 — Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processor 부재)가 그대로 적용되고, 별도로 다시 논증할 필요 없음. 위 "하드 경계"와 같은 이유로, `Emit`이 Modifier의 정적 flatten과 충돌할 걱정 자체가 -성립하지 않음(둘이 만날 -지점이 없음). +성립하지 않음(둘이 만날 지점이 없음). ## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 diff --git a/.claude/question.md b/.claude/question.md index b5d0563..f6a4a37 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -87,10 +87,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션 재조정" 절 참고. -- **`Bound`(3순위, 사소함, 2026-08-07 일곱 번째 세션 추가)**: Observer/ - Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 - 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` - "이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭. +- **[해소됨, 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` @@ -138,11 +138,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 일반 반응형 프로퍼티 바인딩(`BackgroundColor3 = store.color`, 애니메이션 없음)이 결국 이름은 "Tween"인 파일을 거쳐가는 건지, 아니면 별도 범용 `Handlers/StoreBind.luau`가 있어야 하는 건지 확정 필요 — 우선순위1-1. -- **`State` 타입 차단(엔지니어링 비용 감수)과 Ref/Slot이 Modifier - 필드에 들어가는 건 UB 방치 — 같은 문서 안에서 정반대 원칙이 근거 설명 - 없이 나란히 적용됨.** 왜 이 경우만 예외로 방어하는지 명문화 필요, 또는 - Luau에서 실제 타입 차단이 가능한지부터 확인(안 되면 그냥 UB로 격하) — - 문서모순 절 + 우선순위2-2. +- **[해소됨, 2026-08-09 세션]** `State`와 Ref/Slot이 Modifier + 필드에 들어가는 것 — 이제 둘 다 `isX` predicate 기반 명시적 `error`로 + 통일(`base/modifier-plan.md` 4번/7번 절, `base/store-semantics.md` + "따름정리" 절). Luau 타입 차단은 "되면 좋은 보너스"로 격하되어 더 + 이상 필수 검증 항목 아님 — 문서모순 절 + 우선순위2-2도 갱신 완료. - ~~`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 nil-hole 함정에 그대로 노출됨~~ — **반영 완료(2026-08-07 열 번째 세션)**. `props.Modifier or None`/`props.Ref or None` 관용구를 필수로 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index c2558af..f56af34 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -313,6 +313,16 @@ Luau 솔버가 막히면 되돌릴 범위가 `store-semantics.md`의 절반 이 ### 2-2. `State` 타입 차단이 Luau에서 실제로 가능한지 검증 계획이 없음 +**[대부분 해소, 2026-08-09 세션]** "가능하면 타입 차단, 안 되면 UB로 +후퇴"라는 원래 걱정 자체가 무의미해짐 — `State`를 이제 +`isModifier` predicate 기반 명시적 `error`로 막기로 확정 +(`base/modifier-plan.md` 7번 정정, `base/store-semantics.md` "따름정리" +절)했으므로, 타입 차단은 성공하든 실패하든 런타임 에러라는 안전망이 +항상 있음 — 아래 "제안"이 우려했던 "조용히 UB로 후퇴" 시나리오 자체가 +발생하지 않음. 타입 차단이 Luau에서 실제로 가능한지는 여전히 미검증이지만, +이제 "되면 좋은 보너스"로 우선순위가 낮아짐 — M0/M7 필수 검증 항목에서 +제외해도 됨. 아래는 원래 발견 당시 기록. + **위치**: `base/modifier-plan.md` 7번. **문제**: "가능하면 타입 시스템으로 아예 못 넣게 막을 것"이라 확정했지만, @@ -501,6 +511,14 @@ M11 착수 시. ### 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`" 절. @@ -565,6 +583,16 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 보이지만, 문서 어디에도 "왜 이 경우엔 원칙에서 예외로 처리하는가"를 명시적으로 인정/정당화하지 않고 그냥 나란히 적혀 있다. 위 2-2 항목 (Luau에서 실제 차단 가능한지)과 묶어서 같이 정리할 문제. + + **[완전 해소, 2026-08-09 세션]** 양쪽 다 이제 같은 메커니즘 — + Ref/Slot(+Observer/Effect/Modifier 자기 자신)이 Modifier *필드*로 + 들어오는 것도, `State`처럼 Modifier가 State/Source *값*으로 + 담기는 것도 전부 `Brand` 기반 `isX` predicate로 런타임에 즉시 + `error`(`base/modifier-plan.md` 4번/7번 절 정정, `base/ + store-semantics.md` "따름정리" 절). 더 이상 "한쪽만 방어" 비대칭이 + 아님 — 남은 차이는 `State` 쪽에 "되면 좋은 보너스"로 + 타입 차단을 추가 시도해볼 여지가 있다는 것뿐(위 2-2번, 미검증이지만 + 더 이상 필수 방어선이 아니라 우선순위 낮음). - **Destroying 훅 신뢰도에 대한 서술이 `lifecycle-pattern.md` 내부에서도, `framework-comparison-findings.md`와의 사이에서도 어긋남** — 위 1-6 항목에 상세, 여기서는 "아직 아무도 하나의 확정 문장으로 정리 안 함"이라는 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 9db5085..f8439b3 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -126,6 +126,37 @@ delayTime)`는 순수 포지셔널 생성자인데, Luau엔 named call 문법이 구체적인 필드 이름/기본값/`TweenInfo` 재사용 경로의 정확한 문법은 아직 확정 아님 — 나중 논의 대상으로 남김. +## 초기 진입 애니메이션(`initValue`)과 트윈 우회(`useTween`) — 둘 다 미확정 (2026-08-09 세션) + +**아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** 사용자가 두 +실사용 시나리오에서 출발한 제안: + +1. **`Tween{initValue=...}`(검토 후 필요성 낮은 쪽으로 기움)** — 동기: + 다이얼로그가 아래에서 위로 올라오며 열리는 것처럼, 처음 마운트될 때 + 시작 위치에서 목표값으로 자연스럽게 움직이는 "enter" 애니메이션을 + 내고 싶은데, 지금 구조로는 첫 마운트 시 이미 목표값이 프로퍼티에 + 세팅된 채로 시작해 트윈이 걸리지 않음. `initValue`로 먼저 그 값을 + 세팅해두고 그 다음 실제 목표값으로 트윈을 트리거하면 될 것 같다는 + 아이디어. **사용자가 스스로 재검토하며 "필요 없는 것 같다"는 쪽으로 + 기움** — 정확한 반례(재process/재마운트 시 `initValue`를 매번 다시 + 적용하면 안 되는데 "최초 1회"를 무엇으로 판별할지가 또 다른 설계 + 문제가 될 수 있음)는 다음 세션에서 확인 필요, 완전 폐기는 아직 아님. +2. **`Tween{useTween = state}`(필요성은 확인, 모양은 미정)** — + 동기: `Visible`로 여닫는 다이얼로그가 항상 아래에서 위로 슬라이드 + 올라와야 하는 게 기본이지만, 특정 상황(접근성 설정으로 애니메이션을 + 완전히 끄고 싶은 경우 등)엔 트윈을 건너뛰고 즉시 목표값으로 스냅해야 + 함 — 트윈을 무시하고 값을 그냥 세팅할 방법이 필요하다는 문제의식. + 제안 동작: 트윈이 실행되려는 시점에 먼저 `useTween:Get()`을 읽어서 + `false`면 진행 중이던 트윈을 전부 죽이고 목표값으로 즉시 `Set`, + `true`(또는 생략 시 기본값)면 평소대로 트윈. + +**결론(미확정)**: `initValue`는 필요 없어 보이지만(사용자 소견) 완전 +폐기는 아직 안 함. `useTween`류 트윈 우회 필요성은 실사용 근거가 +뚜렷해 유지될 가능성이 높지만, **정확한 옵션 키 모양과 문서화 방식이 +아직 전혀 안 정해짐**(사용자 강조: "어떤 모양을 주냐가, 문서화 어떤 +식으로 하냐가 엄청 중요한듯") — 위 "트윈 옵션 값 모양" 절의 편의 필드 +논의와 함께 M11 착수 전 나중 세션에서 마저 정리. + ## 네임스페이스드 객체 (성능상 이유로 보류) 트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 — @@ -143,3 +174,6 @@ CollectionService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페 받을지 편의 필드+기본값으로 받을지, 소견은 후자 쪽이지만 확정 아님. - 자연 완료(Completed) 시 per-instance 북키핑 정리 여부 — `research/pre-implementation-audit.md` 2-10번 참고, M11 착수 시 확정. +- `initValue`(진입 애니메이션)/`useTween`(트윈 우회) — 위 절 참고, + 후자는 필요성은 확인됐으나 모양/문서화 미정, 전자는 필요성 자체가 + 낮은 쪽으로 기움. diff --git a/CLAUDE.md b/CLAUDE.md index 655746a..ae1859a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1699,3 +1699,89 @@ realv 타입이 매 갱신마다 바뀔 수 있는데 '이전 핸들러'를 누 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). `DI`/`D`와 `canExecute`/`isAlive` 두 개만 용어 정리 라운드에 계속 남음 — `question.md` 1순위/3순위 목록 참고. + +## 2026-08-09 세션 — `canBound` 이름 확정, `:Compute`의 `previous` 방어/스코핑 +명확화, Modifier 핸들러 계층 값 UB→error 전환, Tween `initValue`/`useTween` +논의 신설 + +사용자가 `.claude/question.md`를 훑다 나온 여러 짧은 질문/제안을 한 번에 +처리. 전부 `base/`/`research/`에 반영 완료: + +1. **`Bound` → `canBound(handle): boolean` 탑레벨 함수로 확정** — 사용자 + 제안("canExecute 같은 게 있으니 canBound로 넣어도 되지 않나"), raw + 불리언 필드를 직접 노출하는 대신 `canExecute`와 같은 결의 predicate + 함수로 감쌈. 동작 자체(leaf 부착과 `:Subscribe()`는 상호 배타, 위반 + 시 즉시 에러)는 안 바뀜 — `base/bind-system-plan.md` "이중 바인딩 + 금지" 절, `base/effect-plan.md`, `.claude/question.md` 반영. +2. **`:Compute(fn)`의 `previous` 인자 — 오버엔지니어링 의심 기각, 현재 + 설계 유지.** `pre-implementation-audit.md` 3-1이 "클로저 업밸류로 + 이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 데 대해 사용자가 + 직접 반박 — 클로저 업밸류 대안은 IIFE로 감싸는 준비 비용이 오히려 + `previous`라는 인자 하나보다 무겁고 번거로움. **부수적으로 스코핑도 + 명확화**: 처음엔 `self.Cache`처럼 `previous`를 `self`(입력) 쪽에 + 얹는 모양이 제안됐으나, `self`는 `:Compute`의 입력(receiver)이라 + 같은 `self`에서 여러 `:Compute`가 갈라지는 팬아웃(`w:Compute(g1)`, + `w:Compute(g2)`)이 있으면 `self.Cache` 슬롯이 충돌한다는 문제를 + 검토 중 발견 — `previous`는 그 대신 "이 `:Compute` 호출 하나가 만든 + 결과 State 노드" 자신에 귀속되는 것으로 정리(State가 호출마다 새 + 노드를 만든다는 기존 온톨로지의 당연한 귀결이라 새 결정은 아님). + `base/bind-system-plan.md`의 "previous" 절, `pre-implementation-audit.md` + 3-1 반영. +3. **Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ + Modifier)이 들어오면 UB 대신 즉시 `error`로 확정.** 기존 + "권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는 UB"였던 + 것을, 이런 값의 실사용 case가 없다는 게 확인된 이상 조용한 UB보다 + 그 자리에서 막는 쪽이 낫다는 사용자 판단으로 전환 — 이미 있는 + `Brand` 기반 predicate(`isRef`/`isPreRef`/`isObserver`/`isEffect`/ + `isSlot`/`isModifier`)를 제네릭 `__index` setter가 최종 저장 직전에 + 확인하기만 하면 되므로 구현 비용 거의 0. `isSlot`/`isEffect` + predicate가 `Brand` 절에 명시적으로 없던 갭도 같이 보강. + `pre-implementation-audit.md`가 지적했던 "`State`는 방어, + Ref/Slot은 무방비"라는 비일관성이 이걸로 절반 해소(메커니즘 차이는 + 남지만 "막을 가치가 있다"는 판단은 통일) — `base/modifier-plan.md` + "핸들러 계층을 모름" 절, `base/bind-system-plan.md`의 `Brand` 절, + `pre-implementation-audit.md` 문서모순 절 반영. +4. **UI shorthand(UICorner/UIPadding/UIScale)가 Modifier 체이닝에서도 + 되는지 — 이미 확정돼 있던 것 재확인, 새 결정 없음.** `mod:UICorner(8)`은 + 그냥 제네릭 `__index` setter가 `UICorner` 필드를 채우는 것뿐이고, + 그 필드가 Modifier flatten을 거쳐 최종 props 테이블에 얹히든 + `Frame { UICorner = 8 }`처럼 순수 인라인으로 들어가든 UICorner + Handler 입장에선 구분이 없음 — `base/ui-shorthand-plan.md`에 이미 + 명시돼 있던 내용이라 문서 변경 없음. +5. **Tween `initValue`/`useTween` — 새 열린 논의 신설, 확정 아님.** + 사용자가 두 실사용 시나리오(다이얼로그 진입 애니메이션, 트윈 우회)를 + 제기 — `initValue`(첫 마운트 시 시작값을 세팅 후 목표값으로 트윈)는 + 재검토 끝에 필요성이 낮은 쪽으로 기움(재process 시 "최초 1회" 판별 + 문제가 있어 보임), `useTween = state`(트윈을 끄고 즉시 + 스냅)은 필요성은 확인됐으나 정확한 모양/문서화 방식이 전혀 안 + 정해짐 — `research/tween-plan.md`에 신규 절로 반영, M11 착수 전 + 나중 세션에서 마저 정리하기로 함. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정/보강이라 M0 착수 우선순위 자체는 그대로. + +**같은 세션 후속 — `State`도 UB 대신 명시적 `error`로 확정, +"핸들러 계층 값 → error" 원칙을 State/Source 쪽까지 완전히 통일.** +사용자 질문: "Modifier 필드"뿐 아니라 "State/Source 자체의 값이 +Modifier인 경우"(`State`, `modifier-plan.md` 7번)도 같은 +방식으로 막아도 되는지 — 확정. `isModifier` predicate를 +`Source:Set()`/Store 생성 시 eager `Source(default)`/State의 +`:Compute` 결과 캐싱 지점에서 확인해 런타임 `error`, 타입 차단(Luau +가능 여부 미검증)은 필수 방어선이 아니라 되면 좋은 보너스로 격하. +**Slot은 대조적으로 계속 허용** — 사용자 확인("slot은 당연히 가능함, +retract도 되는 애고 런타임 값이라"): Slot/Tag/Attribute/Tween 등은 +정상적으로 process/retract 재귀 경로를 타는 진짜 dispatch 참가자라 +State/Source 값으로 담겨도 기존 재귀 재-dispatch가 그대로 처리해줌 — +Modifier만 예외인 건 Modifier가 애초에 dispatch 경로 자체를 안 타는 +유일한 존재라서. `base/modifier-plan.md` 7번, `base/store-semantics.md` +"따름정리" 절, `research/pre-implementation-audit.md` 2-2/문서모순 절 +(완전 해소로 갱신), `.claude/question.md`, `ROADMAP.md` M7 반영 완료 — +이걸로 `pre-implementation-audit.md`가 지적했던 "State는 +방어, Ref/Slot은 무방비"라는 비일관성이 완전히 해소됨. + +**핸드오버 준비 완료** — 이번 대화(2026-08-08~09에 걸친 세션)에서 나온 +결정은 전부 `base/`/`research/`/`question.md`/`ROADMAP.md`에 반영, +문서 간 참조도 동기화 완료. **다음 세션 예고(사용자 지정)**: Slot과 +"State에서 Slot을 뽑아내는" 키 기반 동적 컬렉션 재조정(가칭 `Keyed`는 +탈락, 최종 이름 미정) — `.claude/question.md` 0번 "키 기반 동적 +컬렉션 재조정"이 이미 최우선 항목으로 잡혀있으니 그걸 이어서 보면 됨. diff --git a/ROADMAP.md b/ROADMAP.md index 3aca8d7..de9c013 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -187,7 +187,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `GuiObjectModifier`)을 섞을 때의 타입 시그니처 실 Luau 테스트 (`modifier-plan.md` 9-2번, 미검증 — 안 되면 일단 `Overridden(...: any): any`로 느슨하게 열어두고 이 항목으로 되돌아올 것) -- [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) +- [ ] `State` 조합에 `isModifier` 기반 명시적 error 적용 + (`modifier-plan.md` 7번, 2026-08-09 세션 확정) — 타입 차단은 + 되면 좋은 보너스로 선택 검증(필수 아님) - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) - [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 + From 23c2a8ae46e7b7fe98ca3eaba9fcb5b0ec47fd58 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 16:04:45 +0900 Subject: [PATCH 51/62] =?UTF-8?q?fix(base):=20question.md/audit=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=9D=98=20stale=20=EB=B2=88=EB=93=A4=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=20=ED=95=B4=EC=86=8C=EB=90=9C=20=EA=B2=83=EA=B3=BC=20?= =?UTF-8?q?=EC=A7=84=EC=A7=9C=20=EC=97=B4=EB=A6=B0=20=EA=B2=83=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 "이전 핸들러 추적 책임 소재"가 이미 Dispatch 체인/retractUnder로 해소됐던 걸 지적 — question.md 164행이 이걸 아직 열린 것처럼 동률/매치실패 처리와 한 bullet에 묶어놓고 있었음. 겸사겸사 previous 인자(이번 세션에 해소), UICorner 매칭 기준(ui-shorthand-plan.md에 진작 확정됐으나 audit 2-11에 표시만 누락)도 같이 동기화. 실제로 남은 건 Slot CRUD 의미론(1-7)과 우선순위 스캔 동률/매치실패 처리(1-3) 두 개뿐. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/question.md | 15 +++++++++++---- .claude/research/pre-implementation-audit.md | 7 +++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.claude/question.md b/.claude/question.md index f6a4a37..a8d30dd 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -161,10 +161,17 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 필요로 함(로드맵 순서 역전)~~ — 반영 완료(2026-08-07 세 번째 세션)**: `LifetimeHandle`/`Relate` 인터페이스(타입만)를 `ROADMAP.md` M2로 옮기고, quad-roblox 실 구현만 M8에 남김 — 우선순위1-9 해소. -- 그 외(Slot CRUD 의미론 미정의, retract 시 "이전 핸들러" 추적 책임 소재, - 우선순위 스캔 동률/매치실패 처리, `:Compute`의 `previous` 인자가 - 오버엔지니어링일 수 있음, UI shorthand의 기존 UICorner 매칭 기준 등)는 - `pre-implementation-audit.md` 본문 참고. +- **[해소됨]** 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에도 해소 표시로 + 동기화. **아직 실제로 열려있는 건 두 개뿐** — Slot CRUD 의미론 + (`add`/`remove`/`clear`) 미정의(1-7), 우선순위 스캔 동률/매치실패 + 처리(1-3) — `pre-implementation-audit.md` 본문 참고. - **[해소됨, 2026-08-08 두 번째 세션]** `Frame { ref }`/`Frame { observer }`처럼 children 배열 숫자 슬롯에 직접 놓는 leaf 값을 매칭·바인드하는 Handler (`(i:number, v=Ref/Observer/PreRef)`)의 패키지 배치 — 원래 제안대로 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index f56af34..476db27 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -487,6 +487,13 @@ 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` 자식이 있으면 재사용, 없으면 From 911ab559ea4a7c10fb73197604e803c8660f1f3e Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 16:36:59 +0900 Subject: [PATCH 52/62] =?UTF-8?q?fix(base):=20=EC=BD=94=ED=8D=BC=EC=8A=A4?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20stale=20=EB=A7=88=EC=BB=A4/=EB=AA=A8?= =?UTF-8?q?=EC=88=9C=20=EA=B0=90=EC=82=AC=20=EB=B0=8F=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94=EB=90=9C=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EC=82=AC=20archive=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미 해소된 결정이 미해결로 표시되거나 문서 간 모순되던 항목 7개 파일 수정, 뒤집힌/무효화된 설계 서술이 정정 표시만 붙은 채 본문에 남아있던 곳을 기존 archive 컨벤션대로 이전(quad2-try 리서치, Observer cleanup 계약, keyed collection state method, debug channel ReplicatedStorage). CLAUDE.md에 세션 로그 반영. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/README.md | 4 + .claude/archive/context-rejected.md | 11 ++- ...ebug-channel-replicatedstorage-rejected.md | 22 +++++ .../keyed-collection-state-method-rejected.md | 26 +++++ .../observer-cleanup-contract-rejected.md | 41 ++++++++ .../quad2-try-research-findings-rejected.md | 90 ++++++++++++++++++ .claude/base/bind-system-plan.md | 95 ++++--------------- .claude/base/component-composition-plan.md | 8 +- .claude/base/effect-plan.md | 15 ++- .claude/question.md | 9 ++ .../research/additional-primitives-plan.md | 13 +-- .claude/research/debug-tooling-plan.md | 22 ++--- .claude/research/documentation-content-map.md | 2 +- CLAUDE.md | 73 ++++++++++++++ ROADMAP.md | 21 ++-- 15 files changed, 330 insertions(+), 122 deletions(-) create mode 100644 .claude/archive/debug-channel-replicatedstorage-rejected.md create mode 100644 .claude/archive/keyed-collection-state-method-rejected.md create mode 100644 .claude/archive/observer-cleanup-contract-rejected.md create mode 100644 .claude/archive/quad2-try-research-findings-rejected.md diff --git a/.claude/README.md b/.claude/README.md index e9fea7c..f676980 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -74,6 +74,10 @@ | `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` 태그로 대체 | ## 참고 diff --git a/.claude/archive/context-rejected.md b/.claude/archive/context-rejected.md index e39ece6..cc66642 100644 --- a/.claude/archive/context-rejected.md +++ b/.claude/archive/context-rejected.md @@ -5,9 +5,14 @@ Store 전달(`props.Theme: Store`처럼 컴포넌트가 필요한 걸 nam parameter로 명시적으로 요구) + 오버라이드가 필요한 지점에서 `Store({...부모값, 변경필드=새값})`을 한 번 명시적으로 만들어 그 지점부터 평소처럼 prop으로 넘기는 것 — 새 primitive 없이 이미 있는 Modifier의 -"merge, 나중 게 이김" 패턴 재사용. 이 파일은 더 이상 능동적으로 참고할 -필요 없음(구현에 안 씀) — "왜 Context가 없는가"가 `quadnomicon`(프레임워크 -설계자용 심화 콘텐츠) 소재로 가치 있어서 사유를 통째로 보존해둔 것. +"merge, 나중 게 이김" 패턴 재사용. **base/ 포인터**: named parameter로 +경계를 넘기는 일반 패턴은 `base/component-composition-plan.md` "1. Named +parameter로 경계를 넘김" 절, merge 패턴 자체는 `base/modifier-plan.md` +2번 절 — 이 결정 자체가 새 primitive를 만들지 "않기로" 한 것이라 전용 +base/ 절이 따로 없고 기존 두 절의 재사용으로 충분함이 이 파일의 결론. +이 파일은 더 이상 능동적으로 참고할 필요 없음(구현에 안 씀) — "왜 Context가 +없는가"가 `quadnomicon`(프레임워크 설계자용 심화 콘텐츠) 소재로 가치 있어서 +사유를 통째로 보존해둔 것. ## 무엇을 검토했었나 diff --git a/.claude/archive/debug-channel-replicatedstorage-rejected.md b/.claude/archive/debug-channel-replicatedstorage-rejected.md new file mode 100644 index 0000000..dcbea5a --- /dev/null +++ b/.claude/archive/debug-channel-replicatedstorage-rejected.md @@ -0,0 +1,22 @@ +# [기각됨] quad-debug 채널을 `ReplicatedStorage`에 자동 생성하는 방식 + +**기각 일시**: 2026-08-06 세션. **현재 유효한 설계**: `research/ +debug-tooling-plan.md` "데이터 채널" 절 — Bindable을 quad 모듈 자신의 +Instance 트리 안(quad가 이미 설치돼 있는 위치 그대로)에 두고 +`CollectionService` 태그로 노출, 플러그인은 `GetTagged(tag)`로 찾음 +(`GetDescendants()` 전체 순회 불필요). 이 파일은 더 이상 능동적으로 참고할 +필요 없음(구현에 안 씀) — 사유를 짧게 보존해둔 것. + +## 무엇을 검토했었나 + +quad-debug-roblox가 초기화 시 `ReplicatedStorage` 밑에 잘 알려진 이름으로 +Bindable을 만들어 노출하는 방식. + +## 기각 이유 + +개발자가 의도하지 않은 Instance를 게임 트리에 주입하는, 부작용이 큰 행위라 +기각(사용자 정정). `ReplicatedStorage`는 개발자 자신의 게임 트리이지 +quad가 마음대로 채워도 되는 공간이 아님 — quad 모듈 자신의 Instance 트리 +안에 두면 이 문제 자체가 없고, `CollectionService` 태그를 쓰면 플러그인이 +quad가 어디 설치됐는지 몰라도 바로 찾을 수 있어 `ReplicatedStorage`에 둬야 +할 이유도 애초에 없었음. diff --git a/.claude/archive/keyed-collection-state-method-rejected.md b/.claude/archive/keyed-collection-state-method-rejected.md new file mode 100644 index 0000000..310f845 --- /dev/null +++ b/.claude/archive/keyed-collection-state-method-rejected.md @@ -0,0 +1,26 @@ +# [기각됨] 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두는 안 + +**기각 일시**: `research/additional-primitives-plan.md` 논의 도중(날짜 미상, +"이전 라운드"로만 기록). **현재 유효한 설계**: `research/ +additional-primitives-plan.md` "폼 팩터" 절 — 이 프리미티브는 자유 함수로 +두고, `data` 인자가 plain array/table이든 `State`/`Source`든 +둘 다 받는 폴리모픽 컨벤션(quad의 leaf 프로퍼티가 이미 쓰는 "리터럴 또는 +State 둘 다" 관례와 동일)을 따름. 이름 자체는 아직 미정 — 이 프리미티브의 +최종 설계는 여전히 열려있는 질문이라 `question.md`/`additional-primitives-plan.md` +본문을 계속 참고할 것, 이 파일은 "왜 State 메소드가 아닌가"라는 기각 +사유만 보존. + +## 무엇을 검토했었나 + +"독립 프리미티브 vs 원천 종속 파생 데이터" 원칙(Source/Ref/Store/Modifier= +독립 프리미티브, State/Observer=원천에 종속된 파생 데이터)을 그대로 적용해, +이 재조정 프리미티브도 `state:Keyed(...)`처럼 **State의 메소드**로 두자는 +제안. + +## 기각 이유 + +Source를 안 쓰는 컴포넌트는 이 메소드 자체에 접근을 못 함 — 정적 데이터 +(한 번만 렌더되고 다시는 안 바뀌는 리스트)를 키 기반으로 렌더링하고 싶을 +뿐인데, 굳이 `Source(정적데이터)`로 감싸야 접근 가능하다면 불필요한 강제. +"독립 프리미티브 vs 파생 데이터" 원칙 자체가 틀린 게 아니라, 이 프리미티브가 +그 분류 어디에도 깔끔히 안 맞는 케이스였다는 게 재검토 결과. diff --git a/.claude/archive/observer-cleanup-contract-rejected.md b/.claude/archive/observer-cleanup-contract-rejected.md new file mode 100644 index 0000000..7305665 --- /dev/null +++ b/.claude/archive/observer-cleanup-contract-rejected.md @@ -0,0 +1,41 @@ +# [기각됨] `Observer` 자체에 React `useEffect`식 cleanup 반환 계약 추가 + +**기각 일시**: 2026-08-07 여섯 번째 세션. **현재 유효한 설계**: `base/ +effect-plan.md` "Effect와 Observer의 관계" 절 — `Observer`의 기본 계약은 +재실행 신호만 주고 cleanup은 클로저로 직접 처리, 자동 cleanup 배선이 +필요하면 opt-in 상위 계층인 `Effect(fn, state?)`를 쓸 것. 이 파일은 더 +이상 능동적으로 참고할 필요 없음(구현에 안 씀) — "왜 Observer 자체에 +cleanup 계약을 안 넣었는가"가 `quadnomicon`(프레임워크 설계자용 심화 +콘텐츠) 소재로 가치 있어서 사유를 보존해둔 것. + +## 무엇을 검토했었나 + +React `useEffect`류 패턴 — `state:Observer(fn)`의 `fn`이 `nil | () -> ()`를 +반환하면, 다음 재실행 직전에 quad가 그 반환값을 자동으로 호출해주는 안. + +## 기각 이유 + +클로저 업밸류로 이미 쉽게 되고 잘 작동함: + +```lua +local lastConn +state:Observer(function() + if lastConn then lastConn:Disconnect() end + lastConn = ... +end) +``` + +**Observer 자체**가 이걸 대신 배선해줘야 할 이유가 약함 — 반환값을 잡아뒀다가 +다음 실행 전에 불러주는 기능을 Observer 코어에 넣으면, 그 계약을 안 쓰는 +대다수 사용처까지 복잡도가 늘어나는데 클로저로 이미 공짜로 되는 걸 다시 +API 표면으로 만드는 셈. + +## 왜 완전히 헛수고는 아니었나 — Effect 설계와 상충하지 않음 + +이 기각과 이후 확정된 `Effect(fn, state?)` 설계는 상충하지 않는다 — 그때 +기각한 건 "Observer 자체에 이 복잡도를 넣지 말자"였지 "이 패턴 자체가 +무용하다"가 아니었음. 자동 cleanup 배선이 필요한 사람만 opt-in으로 쓰는 +별도 계층(`Effect`)으로 분리해 얹었을 뿐, `Observer`의 기본 계약(재실행 +신호만, cleanup은 클로저로 직접)은 그대로 가볍게 유지됨 — `Effect`가 +내부적으로 `state:Observer(...)`를 조합해 이 패턴을 상위 계층에서 정확히 +구현한다(`base/effect-plan.md` 참고). diff --git a/.claude/archive/quad2-try-research-findings-rejected.md b/.claude/archive/quad2-try-research-findings-rejected.md new file mode 100644 index 0000000..405bd3c --- /dev/null +++ b/.claude/archive/quad2-try-research-findings-rejected.md @@ -0,0 +1,90 @@ +# [기각됨] quad2-try 리서치 — 죽은 접근 4가지 + Unix 파이프 영감의 최종 정리 + +**기각/해소 일시**: 2026-08-04(2차 라운드). **현재 유효한 설계**: +`base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 — `state(state)`로 +기존 state의 결과를 받아 새 state를 만드는 조합 모델이 최종 결론, Slot은 +`base/slot-plan.md`의 from-scratch 설계, `:With` 이름은 이미 확정. 이 파일은 +더 이상 능동적으로 참고할 필요 없음(구현에 안 씀, "OOP 상속/커스텀 파서/Slot +스텁/Pipe copy-on-write는 확인된 죽은 접근이라 반복 조사 금지"라는 결론 +한 줄만 `CLAUDE.md`/`base/bind-system-plan.md`에 포인터로 남으면 충분) — +"이전 시도에서 뭘 배웠는가"가 `quadnomicon`(프레임워크 설계자용 심화 콘텐츠) +소재로 가치 있어서 조사 과정과 근거를 통째로 보존해둔 것. + +## 배경 — quad는 원래 Unix 파이프에서 영감을 받아 설계됨 + +quad는 원래 파이프라인/스트림 개념에서 영감을 받아 만들어짐. 이상적으로는 +store에서 한 값을 추적(track)하면 "State"가 나오고, 거기에 `compute`를 +적용하면 또 다른 "State"가 나오는 식 — Unix의 `(cat a; cat b) | while +read ...`처럼 State끼리 자유롭게 합성/파이핑 가능한 것이 최종 목표. +`:With`의 두 번째 인자도 다른 `:Compute`의 결과물(State)을 그대로 받을 수 +있어야 이상적이었음. + +이 목표를 실제로 어떻게 구현할지에 두 갈래 긴장이 있었음: (1) Compute +체인이 자기 자신을 mutable하게 바꾸는 방식(엔지니어링 비용 낮지만 +공유/합성이 깨짐) vs (2) 명시적 `State:fromState(state)`류 비-mutating +생성자(합성은 안전, 비용 미확정). `.claude/initreq/quad2-try/out/quad-core`에 +정확히 이 문제를 다뤘던 이전 재작성 시도가 있어서 그걸 조사해 답을 찾으려 +했음. + +## 조사 결과 — 확인된 죽은 접근, 절대 반복하지 말 것 + +- **OOP 상속(`Base:Extends`) 구조**가 `Source`/`State`/`Pipe`/`Store`/ + `Event`/`Action`+8개 서브타입 전체에 퍼져 있었음 — 모든 서브클래스 + 생성자마다 `self._super._constructor(self, ...)`를 수동으로 호출해야 + 하고(빼먹기 쉬움, 컴파일러가 검증 안 함), private/protected는 `_` 접두사 + 관례일 뿐 실제 캡슐화가 전혀 없었으며, `Base:IsInstance`가 수동 유지되는 + `_proto`/`_super` 연결 리스트를 순회하는 런타임 전용 타입 체크라 Luau + 정적 타입 시스템이 전혀 못 봄. 사용자가 우려한 그대로 확인됨 — 상속 + 기반 설계 금지. +- **`--&` 커스텀 파서 시도**는 완전히 죽은 코드였음 — 6개 파일에 156줄의 + 주석 기반 타입/가시성 어노테이션이 있었지만, 이걸 실제로 소비할 도구 + (`quad-gen`, `quad-lang`)는 둘 다 완전히 빈 디렉토리였음. 오타(`@clsas`를 + `@class`로 못 고침)가 안 잡힌 채 남아있었고, 같은 주석 마커 아래 전혀 다른 + Lua5.1-호환 트랜스파일러 지시어까지 섞여 있었음 — 파서가 한 번도 제대로 + 동작한 적 없다는 명백한 증거. +- **Slot은 이 시도에서도 사실상 빈 스텁**이었음 — `Insert`의 실제 구현부가 + 전부 주석 처리되어 있고, `Notify()`도 빈 함수. 심지어 구 v1(`quad-2`)의 + `DEV_CHANGELOG.txt`에도 "TODO: slot 기능 구현"이 마지막까지 미완료로 + 남아있었음 — 가져올 게 전혀 없음, `base/slot-plan.md`의 from-scratch + 설계를 그대로 진행하면 됨(재조사 불필요). +- 다른 서브패키지(`quad-roblox`/`quad-gtk`/`quad-lang`/`quad-gen`/ + `quad-compat`/`quad-debug`/`quad-docs`)는 전부 파일이 0개인 빈 디렉토리 + — `quad-core` 밖엔 참고할 게 없음. +- `Store:Pipe`/`Store:Value` 연동이 담긴 유일한 두 예제 콜사이트 + (`slot.luau:31-41`)조차 존재하지 않는 `Store:Value` 메서드를 호출하는 등 + 실제로 동작 검증된 적이 없는 죽은 스크래치 코드였음 — 이 프로토타입은 + 끝까지 실사용 검증을 통과한 적이 없음. +- **`Pipe`가 mutate-vs-`fromState` 긴장 관계에 제시했던 절충안** — + "체이닝된 `Compute`/`Add`/... 호출은 자신이 액션 리스트의 유일한 + '끝(tip)'일 때만 공유 배열에 그대로 append(뮤테이션), 이미 다른 코드가 + 그 지점 이후로 체인을 확장해버렸다면 배열을 복사한 뒤 새 `Pipe` 객체를 + 반환"하는 copy-on-write 방식 — 한때는 위 (1)/(2) 긴장을 풀어보려 한 + 유일한 시도로서 다시 설계해볼 후보였으나, 최종적으로 폐기됨 — + `state(state)` 조합 모델이 소유권/버전 가드 없이도 같은 문제를 더 + 간단히 풀어서 이 절충안 자체가 불필요해짐. 원본이 갖고 있던 진짜 결함 + (소유권/버전 관리 없이 경쟁 상황에 취약, 테스트/실사용 검증도 없었음)도 + 기록으로 남김. + +## 건질 만한 것 (인체공학/아이디어만, 코드는 아님) + +- **`store:Pipe(key):Compute(fn)` 같은 왼쪽에서 오른쪽으로 읽히는 파이프 + 문법 자체**는 목표로 유지할 가치가 있다고 판단됐음 — 실제로 이후 + `:With`+`:Compute` 체이닝으로 달성됨. +- **`Depend(...)` 액션** — 계산값에는 관여하지 않고 오직 "이 소스가 바뀌면 + 다시 계산하라"는 추가 의존성만 등록하는 값-투명(value-transparent) no-op + 액션. 작지만 깔끔한 아이디어로 기록됐으나, 이후 실제 설계에서 별도 + 프리미티브로 채택되지는 않음(`:With(...)` 가변인자로 같은 효과를 얻음). +- **흥미로운 발견**: 스크래치 파일(`out/asdf`)에 남아있던 더 이전 버전의 + 파이핑 스케치가 정확히 `Pipe(store.background):With(store.transparency, + globalStore.test):Compute(fn)` 모양이었음 — 실제 구현으로 넘어가며 + `:Depend()`+포지셔널 인자로 바뀌었지만, `:With(...)` 네이밍은 이후 + 라운드에서 다시 요청된 것과 정확히 일치 — 우연이 아니라 원래 지향점이었던 + 것으로 보이며, `:With` 이름 채택에 힘을 실어준 방증. + +## 결론 + +이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — `:With` 네이밍은 +quad-v2 설계에 그대로 살아남았지만, Pipe의 copy-on-write 절충안은 +2026-08-04 검증 라운드에서 폐기되고 `state(state)` 조합 모델로 대체됨. +Unix 파이프 영감이라는 원래 동기 자체는 `:With`+`:Compute` 체이닝으로 +충분히 달성된 것으로 최종 판단. diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index f67ecae..40a23e6 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -643,9 +643,10 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 Ref는 그냥 "지금 뭐가 들어있나/누가 채워주길 기다리나"만 다루는 즉시 값 박스이고, 파생값이 필요하면 Store/State(`:With`+`:Compute`)를 쓸 것 — 둘을 섞으려 하지 말 것. -- **용어 정리 합류 대상**: Ref의 정의 자체가 "instance를 얻는 것"에서 - "범용 값 박스"로 넓어졌으므로, 진행 중인 용어 정리(`question.md` 1번) - 때 이름이 여전히 맞는지 같이 재검토할 것. +- **[해소됨, 2026-08-08 다섯 번째 세션]** 위 정의 확장을 감안해도 `Ref` + 이름은 그대로 확정 — "지연 없는 확정된 값 박스"라는 정의가 leaf로 + 담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이 + 없다는 결론, 용어 정리 대상에서 제외됨. ### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 @@ -1268,25 +1269,11 @@ end 두 경로를 동시에 쓰고 싶으면 각각 독립된 새 `Effect(...)`/ `state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. -## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 - -**배경**: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. -이상적으로는 store에서 한 값을 추적(track)하면 "State"가 나오고, 거기에 -`compute`를 적용하면 또 다른 "State"가 나오는 식 — Unix의 `(cat a; cat b) | while -read ...`처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최종 목표. `:With`의 -두 번째 인자(`b`)도 다른 `:Compute`의 결과물(State)을 그대로 받을 수 있어야 -이상적. - -**해소됨(2차 라운드) — 두 갈래 방식 중 실질적으로 옵션 2 방향으로 정리됨**: -당시엔 (1) Compute 체인이 자기 자신을 mutable하게 바꾸는 방식(엔지니어링 비용 -낮지만 공유/합성이 깨짐) vs (2) 명시적 `State:fromState(state)`류 비-mutating -생성자(합성은 안전, 비용 미확정) 둘로 긴장이 있었으나, 실제 확정된 모델은 -아래 "Store/State/Source 온톨로지" 절의 **`state(state)`로 기존 state의 -결과를 받아 새 state를 만드는 조합**임 — 매번 새 State를 만든다는 점에서 -옵션 2와 같은 축(비-mutating)이고, 별도 `fromState`/`Pipe` 콤비네이터 타입 -없이도 `state(state)` 하나로 충분하다는 게 최종 결론(`Pipe` 후보는 폐기). -`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 같은 -축의 해법. +**quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는 +`archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만 +남기면: 목표(State끼리 자유롭게 합성/파이핑)는 아래 "Store/State/Source +온톨로지" 절의 `state(state)` 조합 모델로 달성됨, 별도 `Pipe`/`fromState` +콤비네이터 타입은 불필요로 폐기. ## Store/State/Source 온톨로지 — 핵심 메커니즘 확정 (2026-08-04 2차 라운드) @@ -1579,63 +1566,13 @@ Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실 ## quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지 `.claude/initreq/quad2-try/out/quad-core`에 정확히 이 문제(Unix 파이프 영감의 -State/스트림)를 다뤘던 이전 시도가 있었음. 조사 결과 요약: - -**확인된 죽은 접근 — 절대 반복하지 말 것:** -- **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` 메서드를 호출하는 등 실제로 동작 검증된 적이 - 없는 죽은 스크래치 코드였음 — 이 프로토타입은 끝까지 실사용 검증을 통과한 - 적이 없음. - -**건질 만한 것 (인체공학/아이디어만, 코드는 아님):** -- **`store:Pipe(key):Compute(fn)` 같은 왼쪽에서 오른쪽으로 읽히는 파이프 - 문법 자체**는 목표로 유지할 가치가 있음. -- **`Pipe`가 mutate-vs-`fromState` 긴장 관계에 제시했던 절충안** — "체이닝된 - `Compute`/`Add`/... 호출은 자신이 액션 리스트의 유일한 '끝(tip)'일 때만 공유 - 배열에 그대로 append(뮤테이션), 이미 다른 코드가 그 지점 이후로 체인을 - 확장해버렸다면 배열을 복사한 뒤 새 `Pipe` 객체를 반환"하는 **copy-on-write - 방식** — 한때는 이 문서의 "mutate-in-place vs `fromState`" 긴장을 풀어보려 - 한 유일한 시도로서 다시 설계해볼 후보였으나, **아래 "종합"에서 최종적으로 - 폐기됨** — `state(state)` 조합 모델이 소유권/버전 가드 없이도 같은 문제를 - 더 간단히 풀어서 이 절충안 자체가 불필요해짐. 원본이 갖고 있던 진짜 결함 - (소유권/버전 관리 없이 경쟁 상황에 취약, 테스트/실사용 검증도 없었음)은 - 기록으로만 남김. -- **`Depend(...)` 액션** — 계산값에는 관여하지 않고 오직 "이 소스가 바뀌면 - 다시 계산하라"는 추가 의존성만 등록하는 값-투명(value-transparent) no-op - 액션. 작지만 깔끔한 아이디어라 이름 그대로 채택할 만함. -- **흥미로운 발견**: 스크래치 파일(`out/asdf`)에 남아있던 더 이전 버전의 - 파이핑 스케치가 정확히 `Pipe(store.background):With(store.transparency, - globalStore.test):Compute(fn)` 모양이었음 — 실제 구현으로 넘어가며 - `:Depend()`+포지셔널 인자로 바뀌었지만, **`:With(...)` 네이밍은 사용자가 - 이번 라운드에서 다시 요청한 것과 정확히 일치** — 우연이 아니라 원래 - 지향점이었던 것으로 보임, `:With` 이름 채택에 힘을 실어줌. - -**종합**: 이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — `Depend`/`:With` -네이밍은 quad-v2 설계에 그대로 살려볼 가치가 있는 아이디어로 남지만, **Pipe의 -copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기 쪽으로 재평가됨** -(위 "Store/State/Source 온톨로지" 절 참고 — State 자체가 파이핑 결합체이고 -`state(state)`로 분기하는 쪽이 더 간단하다는 사용자의 최신 판단). +State/스트림)를 다뤘던 이전 시도가 있어 조사함 — **확인된 죽은 접근(OOP 상속 +`Base:Extends`/`--&` 커스텀 파서/Slot 빈 스텁/`Pipe` copy-on-write 절충안)은 +절대 반복 조사하지 말 것**, 상세 근거와 "건질 만한 것"(`:With` 이름의 +방증 등)은 `archive/quad2-try-research-findings-rejected.md` 참고 — 이 +조사의 최종 결론은 이미 아래 "Store/State/Source 온톨로지" 절의 `state(state)` +조합 모델로 대체되어 있고 Slot은 `base/slot-plan.md`의 from-scratch 설계를 +그대로 쓰면 됨(재조사 불필요). ## 확정된 것 (더 이상 열린 질문 아님) diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 3d1f094..8cbc275 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -34,7 +34,8 @@ v1의 `Class.Extend()`(Init/Render/AfterRender/Getter/Setter/UpdateTriggers, 자동 연결과 동일) — quad.qwreey.kr 튜토리얼 `11_extend/` 문서 원문 확인. 이 두 역할이 v2 온톨로지에서는 이미 갈라져 있음: (1)은 확정된 **Ref**가 -대체, (2)는 이번 논의에서 다루는 Source 양방향 프록시가 대체. +대체, (2)는 아래 "4. Source 직접 전달" 절이 대체(폐기된 `StoreSource` +프록시와는 다른 개념 — 혼동 방지용으로 명명을 맞춤). ## 수렴된 결론 @@ -274,8 +275,9 @@ caller가 named parameter 하나에 여러 modifier를 몰아넣고 싶을 때 같아"). `MyComp { Modifier = Modifier.Overridden(theme, override) }` → 컴포넌트 내부는 항상 이미 합쳐진 단일 값만 받으므로 컴포넌트 저작자가 배열 처리를 신경 쓸 필요 없음. Ref는 필드 충돌 개념이 없어 이 문제 자체가 없음(여러 -Ref를 받으면 그냥 전부 실행하면 됨, `modifier-plan.md` §4-2) — 별도 결합 -유틸 불필요. **정확한 동작(baked 값 교체 경고, 순서 의존성, `Apply`와의 +Ref를 받으면 그냥 전부 실행하면 됨 — Ref 콜백 리스트는 애초에 여러 등록을 +누적하도록 설계돼 있음, `bind-system-plan.md`의 Ref 콜백/대기자 절) — 별도 +결합 유틸 불필요. **정확한 동작(baked 값 교체 경고, 순서 의존성, `Apply`와의 역할 구분, `:Peek`/`isState`)은 `base/modifier-plan.md` 9번 절이 최종 소스** — `Merge`로 전부 대체해 `Apply`만 강제하는 방안도 이번에 검토했으나, 이 3번 절에서 확정한 실사용 니즈(단일 named parameter 슬롯에 독립적으로 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 7427cbc..ab75825 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -59,15 +59,12 @@ leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer 비쌈) — 필요할 때만 쓰는 걸로 충분. **Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React -`useEffect`류로 `fn`이 `nil | () -> ()`를 반환하면 다음 재실행 직전에 그걸 -불러주는 안을 검토했으나, 클로저 업밸류로 이미 쉽게 되고 잘 작동해서(`local -lastConn; state:Observer(function() if lastConn then lastConn:Disconnect() -end; lastConn = ... end)`) **Observer 자체**가 이걸 대신해줄 이유는 여전히 -약함. **이 기각과 위 Effect 설계는 상충하지 않는다** — 그때 기각한 건 -"Observer 자체에 이 복잡도를 넣지 말자"였지 "이 패턴 자체가 무용하다"가 -아니었음. 자동 cleanup 배선이 필요한 사람만 opt-in으로 쓰는 별도 계층 -(Effect)으로 분리해 얹었을 뿐, Observer의 기본 계약(재실행 신호만, cleanup은 -클로저로 직접)은 그대로 가볍게 유지됨. +`useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나, +클로저 업밸류로 이미 충분해 채택 안 함. 이 기각은 위 Effect 설계와 +상충하지 않음(그때 기각한 건 "Observer 자체에 이 복잡도를 넣지 말자"였지 +패턴 자체의 무용함이 아니었고, `Effect`가 opt-in 상위 계층으로 정확히 +이 패턴을 제공함) — 상세 경위는 `archive/observer-cleanup-contract-rejected.md` +참고. ## `EffectHandle:Subscribe()`/`:Unsubscribe()` — leaf 없이 쓰는 독립 Effect (2026-08-07 일곱 번째 세션) diff --git a/.claude/question.md b/.claude/question.md index a8d30dd..6c09e18 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -125,6 +125,15 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - `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 착수 전 확인 권장) diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 554712e..090e821 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -12,8 +12,8 @@ context-rejected.md`. 이 문서에는 **아직 완전히 열려있는 것 하 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 지금까지 확정된 -독립 프리미티브(`Source`/`State`/`Store`/`Ref`/`Observer`/`Modifier`/`Slot`/ -`DI`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게 +독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`DI`)+파생 데이터 +(`State`/`Observer`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게 재검토해달라는 요청. ## 조사 방법 @@ -74,12 +74,9 @@ key가 유지, 순서만 변경 → renderFn 재호출 없음, Slot 위치만 ### 폼 팩터 — 자유 함수로 정정 (State 메소드 프레이밍 철회) -이전 라운드에서 "독립 프리미티브 vs 원천 종속 파생 데이터" 원칙을 적용해 -`state:Keyed(...)`처럼 **State의 메소드**로 두자고 제안했는데, 사용자가 -정확한 반례를 지적함: **Source를 안 쓰는 컴포넌트는 이 메소드 자체에 -접근을 못 한다.** 정적 데이터(한 번만 렌더되고 다시는 안 바뀌는 리스트)를 -키 기반으로 렌더링하고 싶을 뿐인데 굳이 `Source(정적데이터)`로 감싸야 -한다면 불필요한 강제다. +`state:Keyed(...)`처럼 State의 메소드로 두려던 초안은 "Source를 안 쓰는 +컴포넌트가 접근 못 함" 반례로 철회됨 — 상세 경위는 +`archive/keyed-collection-state-method-rejected.md` 참고. 재검토 결과 — quad는 이미 **leaf 프로퍼티가 "리터럴 값 또는 State" 둘 다 받는 폴리모픽 컨벤션**을 갖고 있다(`BackgroundColor3 = someColor`도 diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index 7946195..0cb3776 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -238,18 +238,16 @@ trace 이벤트 페이로드는 처음부터 함수/클로저 없이 **순수 재검토)**: "지금 이 순간 일어난 일" 스트림은 BindableEvent, 특정 Instance 선택 시 상세 정보 요청-응답은 BindableFunction — 둘 다 실측 확인됨(위 참고). - - **`ReplicatedStorage` 자동 생성 방식은 기각 — 사용자 정정**: 처음 - 구상은 quad-debug-roblox가 초기화 시 `ReplicatedStorage` 밑에 잘 - 알려진 이름으로 Bindable을 만들어 노출하는 것이었으나, **이건 - 개발자가 의도하지 않은 Instance를 게임 트리에 주입하는, 부작용이 - 큰 행위라 기각**. 대신 Bindable을 **quad 모듈 자신의 Instance - 트리 안**(quad가 이미 설치돼 있는 위치 그대로, 새 위치를 따로 - 안 만듦)에 두고, `CollectionService` 태그로 노출 — 플러그인은 - quad가 어디 설치됐는지 몰라도 `CollectionService:GetTagged(tag)`로 - 바로 찾음(`GetDescendants()`로 전체 트리를 훑어 필터링할 필요 - 없음 — 사용자가 "roblox query descendants" 관련해서 짚어준 더 - 저렴한 방법). 태그를 모듈 자신에 달지 Bindable 각각에 달지는 - 취향 차이 — **사용자 확정**("큰 차이는 없는 엔지니어링 선택"). + - **`ReplicatedStorage` 자동 생성 방식은 기각** — 개발자가 의도하지 + 않은 Instance를 게임 트리에 주입하는 부작용 때문(상세 경위는 + `archive/debug-channel-replicatedstorage-rejected.md`). 대신 Bindable을 + **quad 모듈 자신의 Instance 트리 안**(quad가 이미 설치돼 있는 위치 + 그대로, 새 위치를 따로 안 만듦)에 두고, `CollectionService` 태그로 + 노출 — 플러그인은 quad가 어디 설치됐는지 몰라도 + `CollectionService:GetTagged(tag)`로 바로 찾음(`GetDescendants()`로 + 전체 트리를 훑어 필터링할 필요 없음). 태그를 모듈 자신에 달지 + Bindable 각각에 달지는 취향 차이 — **사용자 확정**("큰 차이는 없는 + 엔지니어링 선택"). - **공통 원칙(사용자 확정)**: 어떤 채널이든 **debug를 안 켰을 땐 CPU/메모리 영향이 사실상 없어야 함**(패시브하게 가볍게만 들고 있거나, 아예 아무것도 안 함) — 무거운 트레이스 함수 실행은 debug를 켠 다음부터만. 이건 위 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index af1986c..7d74850 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -92,7 +92,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 - skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 ### store-semantics.md / tween-plan.md / ui-shorthand-plan.md -- 초심자: Store 생성+`myStore.key = value` 문법 / `store.key`로 State 얻기 개념 / Tween 기본 바인드 키+취소 기본 동작 / UI 숏핸드 인라인 키 기본 예시(`Frame { UIPaddingOffset = 50 }`) +- 초심자: Store 생성+`myStore.key:Set(value)` 문법 / `store.key`로 State 얻기 개념 / Tween 기본 바인드 키+취소 기본 동작 / UI 숏핸드 인라인 키 기본 예시(`Frame { UIPaddingOffset = 50 }`) - api: `:With`+`:Compute` 시그니처(→심화) / `source:Emit()` 존재+"Get() 결과 캐시 금지" 캐비엇(버그 유발 포인트라 api에도 명시 가치 있음, →심화; 2026-08-06 후속 세션에서 `Store:Emit(key)`→`source:Emit()`로 호출부 변경, `store-semantics.md` 참고) / Tween 핸들러가 Instance 직접 받음(Ref 불필요) / retract는 Destroy 시 호출 안 됨(→심화) / UI 숏핸드 키 목록 레퍼런스 표 / Modifier와 순수 인라인 키 동등성 - 심화: Source·Store·State·Observer 온톨로지(독립 프리미티브 vs 파생 데이터 원칙, 생성자 모양 근거) / `Emit`이 Source 전용인 이유(디버깅 그래프 무결성) / `Store`의 T가 Modifier 불가인 이유 / Tween을 반응 그래프 밖 특수 bind key로 둔 이유(Fusion 반면교사) / RoundSize 포팅 불필요 vs UICorner/UIPadding/UIScale 필요 이유 / "작고 opt-in 아닌 편의 기능은 코어 포함" 원칙 - 열린 질문(문서화 보류): tween-plan.md의 오버라이드/삭제후재시작/끝점이동 옵션 키 이름 미정 / ui-shorthand의 RoundSize 완전 드롭 여부 diff --git a/CLAUDE.md b/CLAUDE.md index ae1859a..4b13402 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1785,3 +1785,76 @@ Modifier만 예외인 건 Modifier가 애초에 dispatch 경로 자체를 안 "State에서 Slot을 뽑아내는" 키 기반 동적 컬렉션 재조정(가칭 `Keyed`는 탈락, 최종 이름 미정) — `.claude/question.md` 0번 "키 기반 동적 컬렉션 재조정"이 이미 최우선 항목으로 잡혀있으니 그걸 이어서 보면 됨. + +## 2026-08-09 두 번째 세션 — `.claude/` 코퍼스 전체 stale 마커 감사·수정, +무효화된 인라인 서사 archive 이전 + +새 설계 결정 없음, 순수 문서 정리 세션. 서브에이전트 4개를 병렬로 띄워 +`.claude/` 전체(30여 개 문서 + `ROADMAP.md`/`HUMAN_TODO.md`/`SAFETY.md`/ +`archive/`)를 클러스터별로 감사, "이미 해소됐는데 미해결로 표시된 것"과 +"문서 간 모순"을 찾아 전부 직접 수정(커밋 전 상태 기준). 이어서 사용자 +요청으로 두 번째 라운드 — 뒤집혔거나 무효화된 설계가 정정 표시만 붙은 채 +본문에 전체 서술로 남아있는 곳을 찾아 기존 `archive/*-reversed.md`/ +`*-rejected.md` 컨벤션대로 이전(본문엔 결론+포인터만 남김), 컨텍스트 +낭비 방지 목적. 이것도 서브에이전트 3개 병렬 감사로 후보를 찾은 뒤 직접 +판단해 적용. + +**1차 라운드 — stale 마커/모순 수정 (7개 파일)**: +- `bind-system-plan.md`: `Ref` 이름이 "용어 정리 재검토 대상"으로 남아있던 + 것 — 2026-08-08 다섯 번째 세션에서 이미 확정됐는데 반영 안 됨 → 해소 + 표시로 정정. `component-composition-plan.md` §4-2 인용 오류(그 절은 + 실제로 다른 내용을 다룸 — Ref 필드 충돌 없음의 근거를 잘못 인용)와 + 폐기된 `StoreSource` 프록시와 혼동될 수 있는 "Source 양방향 프록시" + 표현도 정정. +- `documentation-content-map.md`: 폐기된 `myStore.key = value` 대입 + 문법이 예시로 남아있던 것(같은 파일 바로 다음 줄은 `:Set()`으로 옳게 + 써서 자기모순) → 정정. +- `ROADMAP.md`: 세션 인용 오류 2건(`git blame`으로 실제 커밋 시점 확인해 + 정정 — M0의 Source/State 서브타입 항목은 "세 번째 세션", M2의 + `LifetimeHandle` 순서 역전 항목은 "네 번째 세션"이 맞음), `Bound`/ + `None` "가칭" 표기가 이미 이름 확정됐는데 안 지워진 것 2건 정정, M6에 + Slot CRUD 의미론 확정 체크박스 누락돼 있던 것 추가(`pre-implementation-audit.md` + 우선순위1이 이미 지적했던 갭). +- `question.md`: `Tag`/`Added`/`Removed`/`Merged`가 `tag-plan.md`에서 + "여기서 추적 중"이라 주장했지만 실제로 빠져있던 것 추가. +- `archive/context-rejected.md`: 다른 archive 문서와 달리 base/ 포인터가 + 없던 것 보강. +- `additional-primitives-plan.md`: State/Observer를 "독립 프리미티브"로 + 잘못 묶은 표현 정정(확정된 분류는 Source/Store/Ref/Modifier/Slot/DI= + 독립 프리미티브, State/Observer=파생 데이터, 2026-08-08 두 번째 세션 + "Handler는 세 번째 카테고리" 절 참고). + +**2차 라운드 — 무효화된 인라인 서사를 archive로 이전 (신규 archive 4개)**: +- `archive/quad2-try-research-findings-rejected.md` — `bind-system-plan.md`에 + 60줄 넘게 남아있던 quad2-try(폐기된 이전 재작성 시도) 리서치 전문(OOP + 상속/커스텀 파서/Slot 빈 스텁/`Pipe` copy-on-write 4가지 확인된 죽은 + 접근 + Unix 파이프 영감이라는 원래 동기 서사)을 통째로 이전 — "반복 + 조사 금지" 결론과 `state(state)` 조합 모델 포인터만 본문에 남김. +- `archive/observer-cleanup-contract-rejected.md` — `effect-plan.md`의 + "Observer 자체에 React `useEffect`식 cleanup 반환 계약을 추가하는 안" + 기각 서술(코드 예시 포함) 이전. +- `archive/keyed-collection-state-method-rejected.md` — `additional-primitives-plan.md`의 + "키 기반 동적 컬렉션 재조정을 `state:Keyed(...)` State 메소드로 두려던" + 초안 기각 서술 이전(이 프리미티브 자체는 여전히 열린 질문 — 폼 팩터 + 결정 부분만 이전됨). +- `archive/debug-channel-replicatedstorage-rejected.md` — `debug-tooling-plan.md`의 + "`ReplicatedStorage` 자동 생성" 초안 기각 서술 이전. + +각 archive 파일은 기존 컨벤션(`[기각됨]` 제목, "현재 유효한 설계" 포인터, +`quadnomicon` 소재 메모)을 그대로 따름, `README.md`의 archive 인덱스도 +4개 항목 추가로 동기화 완료. + +**의도적으로 손 안 댄 것들**: `bind-system-plan.md`의 PreRef pre-pass +위치 관련 기각 서술, `lifecycle-pattern.md`의 `canExecute` 시그니처 +재정정 단락, `modifier-plan.md` 9-1(b)의 "동질적/이질적" 초안 — 전부 +현재 설계를 정당화하는 근거로 너무 밀착돼 있어서, 분리하면 "왜 이렇게 +안 했는지"가 같이 잘려나가 다음 에이전트가 같은 대안을 또 검토할 +위험이 있다고 판단해 그대로 둠. `documentation-content-map.md`가 최근 +추가된 5개 base 문서(`relate`/`blocker`/`effect`/`tag`/`attribute`-plan.md)의 +초심자/api/심화 분류를 아직 안 갖고 있는 것도 실제 설계 판단(콘텐츠 +분류)이 필요해 손 안 댐 — 문서 자신도 이미 "지금 당장 안 급함"이라고 +인정하고 있음. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, 위 "다음 세션 예고" +Slot/키 기반 컬렉션 재조정도 그대로) — 이번 세션은 순수 문서 위생 +작업이라 설계 우선순위엔 영향 없음. diff --git a/ROADMAP.md b/ROADMAP.md index de9c013..0eb15a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 짜보기(다이아몬드 의존성 케이스 포함 — 이미 invalid면 전파 중단되는지) - [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute(self: Source, ...) -> State`류, self 타이핑 + State 참조 혼합)이 - Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 후속 세션, + Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션, `base/store-semantics.md` "Source가 State를 만족함" 절 — `State`가 `Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 피할 수 있어 보이나 실제 검증 전엔 확정 아님) @@ -97,7 +97,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(`pre-implementation-audit.md` - 우선순위1-9, `question.md` 2번 — 2026-08-07 세 번째 세션에 반영). + 우선순위1-9, `question.md` 2번 — 2026-08-07 네 번째 세션에 반영). **`canExecute`는 `(inst, value) -> boolean`으로 재확정(2026-08-08 세션, `(handle)` 단일 인자 서술을 대체)** — Observer/Effect는 자기 `Subscribed` 상태를 먼저 확인, 그 다음 `inst`의 공유 gcconn(`Relate`로 @@ -143,9 +143,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) -- [ ] Observer/Effect 이중 바인딩 금지 — `Bound`(가칭) 플래그로 leaf 부착과 - `:Subscribe()`가 동시에 걸리면 즉시 `error`(`base/bind-system-plan.md` - "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션) +- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로 leaf + 부착과 `:Subscribe()`가 동시에 걸리면 즉시 `error`(`base/bind-system-plan.md` + "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 이름은 + 2026-08-09 세션에 `canBound`로 확정) - [ ] mock 대상 테스트 ## M4 — 첫 end-to-end 반응형 업데이트 @@ -169,6 +170,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] "여러 Slot이 형제로 섞일 때 순서 보장" 열린 질문 확인(`slot-plan.md`) — Roblox 단일 백엔드로는 급하지 않으면 스킵하고 진행 가능 +- [ ] **Slot의 `add`/`remove`/`clear` CRUD 의미론 확정 — 착수 전 필수.** + `research/pre-implementation-audit.md` 우선순위1이 지적한 갭, 아직 + 미해결(2026-08-07 아홉 번째 세션에서 "사용자가 다음 세션에서 직접 + 다루기로 보류"로 확인, 2026-08-09 세션 말미에도 다음 세션 예고로 + 다시 지목됨). "재마운트 시 즉시 throw"가 개별 element 기준인지 Slot + 컨테이너 전체 기준인지도 이 논의에서 같이 확정할 것. - [ ] base `Dispatch/Slot.luau`(추상 재조정) + quad-roblox `Handlers/Slot.luau` (실제 Parent 조작) @@ -196,8 +203,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `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` 추가) + +- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널 + (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` (`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 "이전 매치 핸들러 추적" 항목과 함께 구현 — Tween store-bind 핸들러와 From baa004ad424d12acf3462f37d493f48a807011d2 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 19:21:22 +0900 Subject: [PATCH 53/62] =?UTF-8?q?decide(base):=20Slot=20CRUD(Add/Remove/Ex?= =?UTF-8?q?tract/Clear/Move/Swap)=C2=B7=EC=9A=94=EC=86=8C=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=9C=EC=95=BD=20=ED=99=95=EC=A0=95,=20Slot:Lis?= =?UTF-8?q?t=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slot CRUD를 Add/Remove/Extract/Clear/Move/Swap 6종으로 확정, get/set 드롭 - "원시 연산 최소화" 원칙 뒤집고 Move/Swap 추가 — Extract+Add 기반 리오더가 Parent 조작 두 번(detach+reattach)이라 무겁고, :List 없이 수동 구성한 Slot엔 리오더 수단 자체가 없었음. Swap은 element 아닌 index 기준(element면 위치 조회에 2n 들어 O(1) 약속이 깨짐) - isMounted 이중 추적 분리(Slot 컨테이너 self._mounted vs 개별 element 전역 weak-set), pre-implementation-audit.md 1-7/1-8 해소 - 요소 타입 제약 신설 — nil/None 둘 다 raw 요소로 금지, 핸들러 계층 값 (Ref/PreRef/Observer/Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(Modifier 필드와 같은 판별 메커니즘 재사용). Slot() 제네릭화 - Slot:List(data, updateFn, keyFn?) 신설 — 키 기반 동적 컬렉션 재조정, research/additional-primitives-plan.md에서 승격·통합. keyFn 생략 시 index를 key로 사용(80% 케이스 커버, 캐스케이드 갱신 트레이드오프 명시) - updateFn(item, index, userdata, prev)이 매 reconcile 사이클마다 호출 — filter/toggle이 Visible 토글이 아니라 실제 파괴/재생성이 되도록 재설계(200+ 항목에서 lazy하지 않은 문제 회피), prev 재사용이 저비용 경로 - :List가 Source를 더 이상 안 만들고 userdata로 그 권한을 updateFn에 위임 — result/userdata 반환값 커플링 제거, 정리 루프는 mounted가 아니라 직전 keyIndex 전체를 순회해야 함(userdata만 살아남는 케이스 커버) - userdata는 GC-native 값만 허용, 명시적 cleanup 필요한 값은 UB로 명문화 — item=nil 정리 훅 추가안은 부모 Destroy 경로에서 안 불려 절반만 동작하므로 기각(retract가 Destroy 시 안 불리는 것과 같은 이유) - question.md/ROADMAP.md/README.md 동기화, slot-plan.md 자체 정합성 재감사 (stale 백로그 섹션 제거 등) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/README.md | 4 +- .claude/base/slot-plan.md | 430 +++++++++++++++++- .claude/question.md | 33 +- .../research/additional-primitives-plan.md | 135 +----- .claude/research/documentation-content-map.md | 9 +- .claude/research/pre-implementation-audit.md | 9 + CLAUDE.md | 295 +++++++++++- ROADMAP.md | 46 +- 8 files changed, 801 insertions(+), 160 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index f676980..4d81a04 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -30,7 +30,7 @@ | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | | `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드) | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`, `keyFn` 생략 시 index 기본값, `updateFn(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn`이 `userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | @@ -58,7 +58,7 @@ | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | | `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 | | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 진짜 불리한 점 중 고칠 만한 것 3개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 | -| `additional-primitives-plan.md` | **[2026-08-07 범위 축소]** 확정/기각된 Effect·Blocker·Batch·Context는 `base/blocker-plan.md`·`base/effect-plan.md`·`archive/`로 분리됨 — 이제 **키 기반 동적 컬렉션 재조정**(Fusion `ForPairs`/Vide `indexes()`류에 대응하는 프리미티브가 quad엔 없음) 하나만 다룸 | 상 — 사용자 판단 대기, 착수 전 M0/M1 스코프에 영향 줄 수 있음 | +| `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1(M0~M4 착수 전 확인 권장) + 11개 우선순위2 + 2개 단순화후보 | 상 — M0 착수 전 최소 우선순위1 항목 확인 권장 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index f0a969a..0ffa9a5 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -6,7 +6,10 @@ architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). `.claude/initreq/raw-userinput.md` "slot을 구현하도록 하기로 했음" 절. Fusion의 `Children` SpecialKey와 Vide의 mount 무가드 비교는 `reference/comparison-fusion-vide.md` 참고 — 결론: **두 라이브러리 어디에도 이런 엄격한 단일 마운트 가드가 없음, -quad의 진짜 개선점.** +quad의 진짜 개선점.** **[2026-08-09 세 번째 세션]** CRUD 의미론 +(`pre-implementation-audit.md` 1-7/1-8) 완전 확정, `research/ +additional-primitives-plan.md`가 다루던 키 기반 동적 컬렉션 재조정도 +`Slot:List(...)` 메소드로 이 문서에 승격·통합 완료 — 아래 참고. ## base/roblox 패키지 경계 (2026-08-04, 5차 라운드 확정) @@ -17,6 +20,12 @@ Handlers/Slot.luau`가 그 위에서 적용/해제만 담당 — 다른 모든 분리와 동일한 패턴(`base/architecture.md`의 소스 트리 참고). Slot 자체는 당연히 Instance들을 담게 될 것으로 취급. +**[2026-08-09 세 번째 세션 보강]** 이 경계가 담당하는 훅은 mount(`Add`)/ +unmount(`Remove`) 둘이 아니라 **reposition(`Move`/`Swap`)까지 셋** — +아래 "CRUD API 확정" 절 참고. reposition은 **Parent를 건드리지 않는다는 +계약만 base가 강제**하고, quad-roblox가 이걸 `SetSiblingIndex`로 구현할지 +(`LayoutOrder` 기반 정렬이라) 사실상 no-op으로 둘지는 구현 선택. + **추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미 만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예: `Frame { Frame {} }`)를 위한 핸들러도 필요 — `quad-roblox/src/Handlers/ @@ -25,9 +34,47 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 " ## 개념 -`add`/`remove`/`clear`/`get`/`set` 등 뮤터블 연산을 지원하는 메타 배열. 실제 -바인드가 일어나면 child로 풀리고, 이 메타 배열에 CRUD를 하면 실제 children이 -적절히 제어됨. +뮤터블 자식 배열. `Slot()`(빈 인스턴스, 인자 없는 바닥 생성자 — 다른 +독립 프리미티브의 `Type(args)` 관습과 동일하되, 무인자라 `T`를 추론할 +수 없어 tbox 명시적 제네릭 적용 `Slot<>()`로 지정)로 만들고, +`Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD로 조작하면 실제 +바인드된 children이 그에 맞춰 갱신됨 — 정확한 시그니처는 아래 "CRUD API +확정" 절 참고(`get`/`set`은 드롭). + +### 요소 타입 제약 (2026-08-09 세 번째 세션) + +- **`nil`/`None` 둘 다 금지 — Slot의 raw 요소는 오직 실제 마운트 가능한 + `T` 값만.** [정정, 같은 세션 후속] 처음엔 "배열 파트는 `nil` 대신 + `None`" 원칙을 그대로 가져와 `None`을 Slot 요소로 허용했었는데, + `:List`의 필터링 요구사항을 구체화하며 재검토한 결과 불필요했음이 + 드러남 — `updateFn`이 "이번엔 렌더 안 함"을 표현하는 건 아래 `:List` + 절에서 **`updateFn`의 반환값을 해석하는 `:List` 자신의 내부 로직**으로 + 처리되고, 그 경우 `rawAdd` 자체가 아예 호출되지 않음(즉 `None`이 실제로 + Slot 배열에 들어갈 일이 없음) — 그래서 raw `Add`가 굳이 `None`을 + 허용해야 할 이유가 없어짐. `element == nil`뿐 아니라 `element == None`도 + `Add`(및 내부 `raw*`)에서 즉시 `error` — "Slot 안엔 실제로 마운트 + 가능한 값만 들어간다"는 단일 규칙으로 단순화. +- **핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier) 금지, 즉시 + `error`** — `Modifier` 필드가 이 값들을 담으면 즉시 `error`로 확정했던 + 것(`modifier-plan.md` 7번)과 같은 판별 메커니즘(`isRef`/`isPreRef`/ + `isObserver`/`isEffect`/`isModifier` Brand predicate)을 그대로 재사용. + 근거: `Dispatch/Leaf.luau`가 처리하는 "children 배열에 `Ref`/`Observer`/ + `PreRef`가 직접 놓이는" 케이스는 **그 컴포넌트가 지금 만들고 있는 + Instance 자기 자신을 가리키는 self-ref 캡처**(`Frame { PreRef():Callback(fn) }`가 + 그 Frame 자신을 잡는 것)라 `inst`가 "지금 생성 중인 바로 그 하나의 + Instance"로 고정돼 있어야 의미가 성립하는데, **Slot은 특정 컴포넌트 + 호출 하나에 묶여있지 않고 이미 존재하는 부모에 나중에 독립적으로 + 붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을 + 가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 — + 특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에 + Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })`). +- **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종 + 마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상 + `T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라 + `D.InstSlot = Slot<>`가 사실상 "그" Slot 타입. `Slot()`가 + 기본값(`T` 생략 시) 없이 항상 명시를 요구하는지, `quad-base`에선 + `any`로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것 + (이 문서 "자식으로 넘기는 클래스 스토어" 절의 기존 미결과 같은 갈래). ## 핵심 제약: 소유권 귀속과 단일 마운트 @@ -43,6 +90,26 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 같은 target에 두 번 `mount()`하면 조용히 두 개의 독립 루트가 생김 — 둘 다 반면교사. +### `isMounted` 이중 추적 분리 (1-8 해소, 2026-08-09 세 번째 세션) + +"한 인스턴스가 다중 마운팅 절대 안 됨"이라는 위 원칙과 아래 "재마운트 시 +즉시 throw"가 원래 하나의 `isMounted`로 뭉뚱그려 서술돼 있었는데, 실제로는 +서로 다른 두 대상을 추적해야 함 — 명시적으로 분리: + +- **Slot 컨테이너 자신**: `self._mounted: boolean`(Slot 인스턴스 필드 + 하나). **트리거 시점은 `Dispatch.process(inst,k,self)`가 이 Slot + 객체에 대해 실제로 호출된 순간**(핸들러 매치 시점) — Instance + `Parent` 대입 완료를 기다리지 않음. 다른 모든 "마운트됨" 판정(PreRef + 소진, Ref 콜백 fire 등)이 전부 dispatch-process 시점 기준이라 여기만 + post-effect 기준으로 가면 일관성이 깨짐. 컴포넌트가 Slot을 prop으로 + 받아 저장만 하고 실제 트리에 안 놓는 경로는 `process`가 애초에 안 + 불려서 이 정의로도 오탐 없음. +- **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등) + 마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임 + ("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). + `Add`가 이 weak-set을 확인(이미 참이면 error)/설정, `Remove`/`Extract` + 둘 다 여기서 제거(둘의 차이는 파괴 여부일 뿐, "마운트 해제"라는 점은 같음). + ## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨 Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 children으로 @@ -117,6 +184,351 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데 **이번 마일스톤에서는 오버엔지니어링으로 판단, 하지 않음** — 필요성이 명확해지면 그때 별도로 다시 논의. +> **범위 명확화(2026-08-09 세 번째 세션)**: 위 "폐기, 옮기지 않음"은 +> **프레임워크가 store-bind 재실행으로 Slot 값 전체를 통째로 갈아치울 +> 때**(retract)만의 얘기 — **사용자가 직접 `Slot:Extract(element)`를 +> 부르는 CRUD 경로는 이것과 다른 시나리오**다. Extract로 뺀 element는 +> 파괴되지 않고 호출부가 소유권을 되찾으며, **임의의 다른 Slot으로 +> 자유롭게 다시 `Add`할 수 있다**(아래 "CRUD API 확정" 절) — retract가 +> "옮기지 않는다"고 확정한 건 프레임워크가 알아서 옮겨주는 자동 portal을 +> 안 만든다는 뜻이지, 사용자가 명시적으로 두 번 호출(`Extract` 후 +> `Add`)해서 옮기는 것 자체를 막는 게 아니다. + +## CRUD API 확정 (2026-08-09 세 번째 세션, 1-7 해소) + +`get`/`set`은 드롭 — 최종 표면(`Move`/`Swap`은 같은 세션 후속 논의에서 +추가, 아래 "원시 최소화 원칙 정정" 참고): + +| 연산 | 시그니처 | 복잡도 | 의미 | +|---|---|---|---| +| `Add` | `Slot:Add(element, index?)` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 | +| `Remove` | `Slot:Remove(element)` | O(n) | 제거 **+ 파괴**(retract/Destroy) | +| `Extract` | `Slot:Extract(element)` | O(n) | 제거, **파괴 안 함** — 호출부가 소유권 회수, 임의의 다른 Slot에 재삽입 가능 | +| `Clear` | `Slot:Clear()` | O(n) | 전체 `Remove`(전부 파괴) — 빈 Slot에 호출해도 no-op | +| `Move` | `Slot:Move(element, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** | +| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 **인덱스**의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** | + +- **식별은 기본적으로 element 레퍼런스 기준**(`Add`/`Remove`/`Extract`/ + `Move`) — element는 컴포넌트 호출/Instance 생성이 만들어낸 구체 값이라 + 항상 아이덴티티로 구분 가능. 인덱스 기준으로 하면 add/remove가 반복될 + 때 호출부가 들고 있던 인덱스가 곧바로 stale해짐. +- **`Swap`만 예외 — 인덱스 기준(`indexA`/`indexB`), element 레퍼런스 + 아님(2026-08-09 세 번째 세션 정정).** element 레퍼런스로 받으면 Slot이 + element→index 역방향 맵을 안 갖고 있는 이상 두 element 각각의 현재 + 위치를 찾는 데 O(n)씩(총 2n) 들어서 **`Swap`이 약속하는 O(1)이 깨짐** + — `Move`는 어차피 시프트 자체가 O(n)이라 element 조회 비용이 묻히지만, + `Swap`은 조회 비용이 곧 전체 비용이라 이 차이가 그대로 드러남. 호출부는 + 보통 "지금 몇 번째 항목과 몇 번째 항목을 바꿀지"를 이미 알고 있는 + 상황(예: 드래그 리오더 UI)이라 인덱스로 받는 게 자연스럽기도 함. +- **열거(iteration)/`Get` API는 지금 안 만듦** — `Slot:List(...)`(아래)는 + 자기 자신의 key→element 맵을 따로 들고 있어 Slot의 내부 상태를 조회할 + 필요가 없음. 다른 실사용이 나오면 그때 추가(YAGNI). +- **에러 조건 — 전부 즉시 `error()`, no-op 없음**(기존 "재마운트 시 throw"와 + 같은 fail-fast 톤): + - `Add`: element가 이미 어딘가(같은 Slot이든 다른 Slot이든) 마운트돼 + 있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을 + CRUD 경로에도 동일 적용. `element`가 `nil`/`None`이거나 핸들러 계층 + 값(Ref/PreRef/Observer/Effect/Modifier)이면 에러 — 위 "요소 타입 제약" 절. + - `Remove`/`Extract`: 그 element가 지금 이 Slot의 멤버가 아니면 에러. + - `Move`: `element`가 이 Slot의 멤버가 아니면 에러. + - `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖(1..현재 개수)이면 에러 — + 단 `Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op. +- **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십 + weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/ + `Remove`/`Extract`보다 저렴함. +- **모든 공개 CRUD는 "가드 확인 + `raw*` 위임"의 얇은 wrapper** — `Add`/ + `Remove`/`Extract`/`Clear`/`Move`/`Swap` 전부 `self._listed`(`:List`가 + 설치돼 있으면 수동 CRUD 금지)만 확인하고 실제 로직은 `rawAdd`/ + `rawRemove`/`rawExtract`/`rawClear`/`rawMove`/`rawSwap`에 있음 — 이 + `raw*` 함수들이 `:List`의 reconcile이 가드 없이 직접 호출하는 바로 + 그 함수(아래 "`Slot:List`" 절의 "구현" 참고). 공개 메소드에 로직이 + 따로 있는 게 아니라 전부 이 한 세트를 공유. +- **재진입성**(Observer/store-bind 재실행 콜백 안에서 `Add`/`Clear`를 + 다시 호출) — 별도 가드 불필요. CRUD는 평범한 동기 테이블 뮤테이션 + + Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로 + 간주"라는 기존 원칙이 그대로 적용됨. +- **`Slot()` 생성자**: 인자 없는 빈 생성자로 확정 — 초기 children을 + 가변인자로 받는 옵션도 검토했으나, "명시적으로 `Add`해야 들어간다" + 쪽이 이 프로젝트의 "매직 없이 명시적" 기조와 더 맞음. + +### 원시 최소화 원칙 정정 — `Move`/`Swap` 공개 API로 추가 (같은 세션 후속) + +`:List`의 리오더 메커니즘을 구체화하던 중, 처음엔 `Extract`+`Add(index)` +조합으로 충분하다고 봐서 "원시 연산 최소화" 원칙에 따라 별도 `Move`/`Swap`을 +안 만들기로 했었는데 — 실제로는 두 가지 공백이 드러나 **뒤집음**: + +1. **`Extract`+`Add`는 리오더치고 너무 무겁다.** `Extract`의 계약이 "제거, + 파괴 안 함, 소유권 회수"라 백엔드가 곧이곧대로 구현하면 실제 Parent + 조작이 두 번(detach+reattach) 일어남 — Roblox에서 `AncestryChanged` + 발화, 잠재적 깜빡임, 불필요한 재바인딩 비용까지 딸려올 수 있음. + 순서만 바뀌는, 매 `:List` 재계산마다 흔히 일어나는 케이스치고 과함. +2. **`:List` 없이 수동으로 Slot을 구성하는 사용자에겐 리오더 수단이 + 아예 없었다** — `Extract`+`Add`도 결국 위 1번 비용을 그대로 지므로 + 대체제가 못 됨. + +둘 다 원시 최소화보다 우선하는 실사용 공백이라 판단, `Move`(O(n), 배열 +splice 의미)와 `Swap`(O(1), 순수 페어 교환)을 공개 CRUD에 추가 — 시간복잡도 +차이를 문서화해서 사용자가 상황에 맞게 고를 근거를 줌. `:List`의 reconcile +자체는 키 기반 diff가 "이 키는 이제 절대 위치 i다"를 산출하지 "A랑 B를 +맞바꿔라"를 산출하지 않으므로 내부적으로는 계속 `Move`(의 가드 없는 버전)만 +사용 — `Swap`은 순수하게 수동 Slot 사용자를 위한 편의 API. + +## `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정 (2026-08-09 세 번째 세션, `research/additional-primitives-plan.md`에서 승격·통합) + +Fusion `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, React +`key` prop에 대응하는 프리미티브 — 데이터 배열을 정체성(key) 기준으로 +diff해서 변경분만 생성/갱신/파괴한다. **독립 타입이 아니라 `Slot`의 +콜론 메소드**로 확정(아래 "왜 자유 함수/새 타입이 아닌가" 참고) — 자기 +자신을 변경하고 자신을 반환, `Ref():Callback(fn)`류의 기존 체이닝 패턴과 +동일: + +``` +Slot():List(data, updateFn, keyFn?) -> Slot -- self +``` + +**파라미터 순서 정정, `keyFn` 선택 인자화 (같은 세션 후속).** 원래 +`(data, keyFn, updateFn)`이었는데, 실사용 대부분(사용자 추정 80%)이 +"item 자체의 정체성 추적 없이 그냥 순번을 key로 써도 충분한" 단순 목록 +(재정렬·중간 삽입/삭제로 인한 identity 보존이 필요 없는 경우)이라 +`keyFn`을 매번 명시하게 하는 게 불필요한 보일러플레이트였음 — `updateFn`을 +필수 인자 자리(두 번째)로, `keyFn`을 선택 인자(세 번째, 생략 시 인덱스를 +그대로 key로 사용하는 `function(item, index) return index end`)로 재배치. +**tradeoff는 명시적으로 문서화 필요**: 인덱스를 key로 쓰면 중간 삽입/삭제 +시 그 뒤 모든 항목이 "다른 item인데 같은 key"로 오인돼 캐스케이드 갱신이 +일어남(파괴/재생성은 없음, 단지 identity 보존이 없을 뿐) — 흔한 업계 +관행(React `key` 생략 시 index 기본값, Vue `v-for` key 없이 쓰는 경우)과 +같은 트레이드오프라 새로 설명할 개념은 아님, 재정렬/중간 삽입이 실제로 +일어나는 목록엔 진짜 `keyFn`을 넘기라고 안내하는 정도로 충분. + +**이름 정정 — `renderFn` → `updateFn` (같은 세션 후속).** 아래 서술하는 +호출 계약이 "새 key가 나타났을 때 1회 렌더"에서 "매 사이클 재호출되어 +갱신 여부를 스스로 판단"으로 바뀌면서, "render"보다 "update"가 실제 +역할을 더 정확히 반영한다고 판단해 이름도 같이 바꿈. + +- `data: {[K]:V} | State<{[K]:V}> | Source<{[K]:V}>` — plain이면 최초 + 1회 배치만 하고 이후 추적 안 함(다시는 안 바뀌므로), State/Source면 + 아래 메커니즘이 계속 동작. 기존 leaf 프로퍼티의 "리터럴 또는 State + 둘 다 받는" 폴리모픽 컨벤션 재사용. +- `keyFn(item, index) -> key`(선택, 생략 시 `index`를 그대로 key로 사용) — + 아이템 값과 인덱스 둘 다 받음. +- **`updateFn(item, index, userdata: UD?, prev: T?): (T | nil, UD?)` + — 매 reconcile 사이클마다 모든 key에 대해 호출됨.** `:List`는 더 이상 + item을 위해 `Source`를 대신 만들어주지 않음(아래 "왜 `Source`를 + `:List`가 안 만드는가" 참고) — `item`/`index`는 매번 그 사이클의 raw + 현재값 그대로 넘어감, 반응형으로 쓸지는 `updateFn`이 알아서 결정. + - **`userdata: UD?`** — 이 key에 대해 지난 호출에서 `updateFn` 자신이 + 반환해둔 두 번째 값을 그대로 돌려받음(첫 호출은 `nil`). 완전히 + opaque — `:List`는 안을 전혀 안 들여다봄. `updateFn`이 원하는 걸 + 아무거나 담아도 됨(item의 `Source`, 여러 파생 State, 로컬 UI + 상태 등). + - **`prev: T?`** — 이 key에 대해 지금 실제로 마운트돼 있는 + element(없으면 `nil`, 첫 호출을 포함해 언제든 가능). + - **반환값 두 개는 서로 완전히 독립** — `:List`가 `result`와 `userdata` + 사이에 어떤 커플링도 안 둠(예: `result`가 `nil`이라고 `userdata`를 + 자동으로 지우지 않음), 그대로 기록만 함. **[정정, 같은 세션 후속]** + 처음엔 "`result`가 `nil`이면 `userdata`도 같이 버림"이었으나, 이러면 + "인스턴스는 파괴하되 다시 나타날 때 재사용하려고 캐시는 남겨두고 + 싶다" 같은 정당한 패턴 자체가 원천 봉쇄됨 — 그럴 이유가 없어 커플링을 + 없앰. 흔한 경우(둘 다 리셋)는 그냥 `return nil` 하나로 충분(Lua가 + 안 받은 반환 슬롯을 알아서 `nil`로 채움), 캐시를 남기고 싶으면 + 명시적으로 `return nil, ud`. + - `updateFn`은 매번 다음 중 하나를 반환: + - **`prev`를 그대로 반환** — "지금 마운트된 걸 계속 쓴다"는 뜻. + 관용구: `if prev and (필터 통과) then ...update ud...; return prev, + ud end`. 실제 마운트/파괴가 없는 **저렴한 경로**. + - **새 값(또는 다른 값)을 반환** — 첫 렌더(이 key 최초 등장) 또는 + 의도적 교체. `prev`가 있었다면 그건 파괴되고 새 값이 그 자리를 + 대신함. + - **첫 번째 값으로 `nil`을 반환** — "지금 이 key는 렌더 안 함"(filter + 탈락 등). `prev`가 있었다면 실제로 파괴됨(단순 `Visible = false` + 아님 — 아래 참고). `None`을 반환해도 동일 취급(둘 다 허용, 편의상 + `nil` 권장 — 반환값이 raw Slot 요소로 직접 들어가는 게 아니라 + `:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw + `nil`/`None` 금지와 안 부딪힘). + - `userdata = userdata or {}`류 lazy-init 관용구가 `UD`가 완전히 자유 + 제네릭인 상태에서도 Luau 타입 시스템이 매끄럽게 좁혀주는지는 **실측 + 필요**(M0/M6 착수 시 확인 항목, 지금 단정 안 함). + +### 왜 매 사이클 호출로 바뀌었는가 — filter/toggle 문제 + +사용자가 제기한 문제: item이 State 변경으로 "더 이상 렌더되면 안 되는" +상태가 될 수 있는데(예: 검색 필터에서 탈락), 기존 "1회만 호출" 모델엔 +이걸 표현할 방법이 없었음. 실무에서 흔한 회피책은 실제로 제거하지 않고 +`Visible = false`만 토글하는 것 — 하지만 이건 **lazy하지 않음**: 필터링된 +항목도 여전히 완전히 살아있는 Instance라 애니메이션/이벤트 연결/재계산이 +계속 돎. 리스트가 200개+가 되면 "보이는 건 20개인데 200개가 전부 계속 +돌아가는" 문제가 실제 비용으로 드러남. + +**해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면 +그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil` +반환으로 **진짜 파괴**되게 함 — Visible 토글이 아니라 실제 Remove. +200개 중 20개만 통과하는 필터면 20개만 실제로 살아있고 나머지 180개는 +정말로 존재하지 않음(애니메이션도 안 돎). + +**"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** — +item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그 +`Source`를 어딘가 저장해야 하는데, `:List`가 그걸 대신 안 만들어주는 +대신(아래 참고) `userdata`라는 전용 채널로 `updateFn`이 직접 관리하게 +함 — filter 탈락 후 재등장해도(Instance는 파괴됐다 새로 만들어져도) +`userdata`를 살려뒀다면 그대로 이어짐(위 "반환값 두 개는 서로 독립" 참고). + +**sort는 이 재설계와 무관, 기존 메커니즘으로 이미 커버됨** — 호출부가 +`data`의 순서를 바꾸면 `keyIndex[key] ~= i` 감지 → `Move`가 그대로 +처리, 새 메커니즘 필요 없음(사용자가 filter와 같이 물었던 것 중 sort는 +원래도 문제가 없었음). + +### 왜 `Source`를 `:List`가 안 만드는가 — item/index를 raw로 넘기는 이유 + +이전 초안은 `:List`가 `itemState`/`indexState`(내부 `Source`)를 강제로 +만들어 `updateFn`에 넘겨줬는데, 재검토 결과 이건 **`:List`가 굳이 강요할 +필요 없는 결정**이었음 — 반응형 바인딩이 필요 없는 단순한 행(예: 매번 +그냥 새로 계산해도 싼 텍스트 하나)까지 전부 `Source` 생성 비용을 억지로 +지게 됨. `userdata`로 이 권한을 완전히 `updateFn` 쪽에 넘기면, 원하는 +item만 자기 `Source`를 만들어 `userdata`에 담고, 나머지는 매번 raw +`item`에서 그냥 다시 계산해도 됨 — 어느 쪽이 나은지는 케이스 by 케이스라 +`:List`가 미리 정할 이유가 없음. + +**부수 효과 — 이전 "item 값은 무조건 재전파, index는 실제 변경시만" +비대칭 백로그가 사라짐.** `:List`가 더 이상 `Source`를 안 만드므로 그 +문제 자체가 `:List` 소관이 아니게 됨 — item/index를 반응형으로 감쌀지, +매번 무조건 `:Set()`할지 조건부로 할지는 전부 `updateFn` 작성자의 선택. + +### `userdata`의 생명주기 제약 — GC-native만 허용, 명시적 cleanup이 필요한 +값은 UB (같은 세션 후속) + +**검토했다가 기각한 대안**: `item`을 `T?`(nilable)로 바꿔서, key가 최종 +제거될 때 `updateFn(nil, index, userdata, prev)`를 한 번 더 불러 "정리할 +기회"를 주는 안 — `if not item then return +end` 관용구로 `userdata` 안에 담긴 리소스(예: `Observer:Subscribe()`한 +구독)를 정리할 수 있게 하자는 아이디어. **기각 — 사용자가 스스로 반례를 +찾음**: 이 훅은 `data`에서 key가 빠져 `reconcile`이 다시 도는 정상 +경로에서만 발화함 — 하지만 **Slot을 담고 있는 부모 Instance 자체가 +`Destroy`되는 경로**(가장 흔한 소멸 경로)는 `reconcile`을 다시 안 돌기 +때문에 이 훅이 전혀 안 불림. 절반만 동작하는 정리 메커니즘은 없는 것보다 +나쁨 — 사용자가 "정리가 보장된다"고 오해하고 `Subscribe`류를 `userdata`에 +넣었다가 Destroy 경로에서 조용히 새는 게 실제로 훨씬 위험한 결과. +`retract`가 Destroy 시엔 절대 안 불린다는 기존 원칙(`base/ +lifecycle-pattern.md` "quad는 라이프사이클 중간에 있지 않다")과 정확히 +같은 이유로, `:List`에 새 반쪽짜리 예외를 만들 이유가 없음. + +**대신 명시적 제약으로 문서화**: **`userdata`에는 반환된 element(또는 +Slot 자신)보다 명시적으로 오래 살아야 하는 값을 담으면 안 됨 — GC만으로 +자연히 정리되는 값만 담을 것(plain 값, `Source`/`State` 등), `:Subscribe()`한 +`Observer`/`Effect`류처럼 명시적 `:Unsubscribe()`가 필요한 값을 담는 건 +UB.** `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의 +무언가가 GC 하나만으로 안 죽는다면 그건 곧 leak. 이건 quad 전역 +GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 그대로 +적용한 것뿐 — 새 원칙 아님. + +### 구현 + +```lua +function Slot:List(data, updateFn, keyFn) + assert(not self._listed, "Slot already has :List installed") + self._listed = true + keyFn = keyFn or function(_, index) return index end + + local mounted, userdata, keyIndex = {}, {}, {} + + local function reconcile(items) + local newKeyIndex, seen = {}, {} + for i, item in ipairs(items) do + local key = keyFn(item, i) + newKeyIndex[key] = i + seen[key] = true + + local prev = mounted[key] + local result, ud = updateFn(item, i, userdata[key], prev) + if result == None then result = nil end -- 편의: None도 nil과 동일 취급 + + if result ~= prev then + if prev ~= nil then rawRemove(self, prev) end -- 파괴 + if result ~= nil then rawAdd(self, result, i) end -- 새로 배치 + mounted[key] = result + elseif prev ~= nil and keyIndex[key] ~= i then + rawMove(self, prev, i) -- 그대로 쓰되 위치만 이동 + end + + userdata[key] = ud -- result와 무관, 그대로 기록 + end + for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key + if not seen[key] then + local prev = mounted[key] + if prev ~= nil then rawRemove(self, prev) end + mounted[key], userdata[key] = nil, nil + end + end + keyIndex = newKeyIndex + end + + if isState(data) then + data:Observer(function() reconcile(data:Get()) end) + -- Observer는 등록 즉시 1회 실행 확정 -> 최초 population도 공짜 + else + reconcile(data) + end + return self +end +``` + +- **`data:Observer(fn)`**: 새 구독 프리미티브 아님 — 2026-08-07 여섯 번째 + 세션에 이미 "등록 즉시 1회 실행" 확정된 그 메소드를 그대로 씀. + `reconcile`은 매번 **현재 전체 스냅샷을 받아 O(n) 단일 패스로 diff** + — 트리 전체를 비교하는 비싼 diff가 아니라 `seen` 셋 하나로 "새 key + 목록에 없는 건 지운다"만 판정하는 React/Vue/Solid류의 표준 key 기반 + 방식, `data`가 참조를 유지한 채 뮤테이션+`Emit()`되는 경로도 지원해야 + 하는 이상 최소 한 번은 훑어야 하는 게 불가피함. +- **`updateFn`을 매번 부르는 게 비싼 게 아닌 이유** — 흔한 경로(`prev` + 그대로 반환)는 함수 호출 하나뿐, 실제 Instance 생성/파괴가 있는 건 + key가 새로 나타나거나/사라지거나/filter로 구조가 바뀌는 경우뿐. + 200개 중 값만 갱신되는 사이클엔 200번의 값싼 함수 호출이 있을 뿐, + 200번의 재구성이 있는 게 아님. +- **`mounted`/`userdata`를 정리하는 루프가 `mounted`가 아니라 이전 + 사이클의 `keyIndex`를 순회하는 이유** — `userdata`가 이제 `result == + nil`이어도 살아남을 수 있어서(위 "반환값 두 개는 서로 독립"), 어떤 + key가 `mounted[key] == nil`인 채로(필터 탈락 상태) `data`에서 완전히 + 사라지면 `pairs(mounted)`로는 그 key가 아예 안 잡혀서 `userdata`가 + 못 치워지고 샘 — 직전 사이클에 실제로 존재했던 **전체** key 집합 + (`keyIndex`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를 + 놓치지 않음. +- **`mounted`/`userdata`/`keyIndex`**: 이 Slot 인스턴스의 평범한 로컬 + 필드(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `self`가 + 살아있는 동안만 존재하면 되고 Slot이 죽으면 클로저도 같이 GC됨. +- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawRemove`/`rawMove`뿐** — + `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임" + 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그 + 셋을 쓸 일이 없을 뿐(제거는 항상 파괴 확정이라 `Extract` 아닌 `Remove` + 경로, 리오더는 항상 절대 위치 이동이라 `Swap` 아닌 `Move` 경로, + `Clear`는 reconcile 단위가 아니라 Slot 전체 단위 연산이라 무관). +- **리오더는 `Move`(의 가드 없는 버전)** — Parent를 안 건드리는 진짜 + 저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로 + 미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정. + +### 왜 자유 함수/새 타입이 아닌가 + +처음엔 `List(data, updateFn, keyFn?) -> Slot` 같은 자유 함수(또는 `Slot`을 +구조적으로 만족하는 새 타입 `List`)로 검토했으나 둘 다 기각: + +- **자유 함수 기각**: `Source(default)`/`Ref(default)`/`Store({defaults})`가 + 지켜온 "`Type(args)` 팩토리 이름 = 반환 타입"이라는 컨벤션이 깨짐 — + `List(...)`이 `Slot`을 반환하면 이름과 실제 타입이 안 맞음. +- **새 서브타입(`List extends Slot`, Source⊇State 같은 구조적 서브타이핑) + 기각**: Source가 State의 서브타입이어야 했던 이유는 Source가 State보다 + 진짜로 더 많은 공개 메소드(`:Set`/`:Emit`)를 갖기 때문 — 반면 이 + 프리미티브는 Slot이 이미 가진 것(`Add`/`Remove`/`Extract`/`Clear`/ + `Move`/`Swap`) 위에 새 공개 메소드를 얹지 않음. 그냥 "자동으로 채워지고 + 관리되는 Slot"일 뿐이라 별도 타입일 이유가 없음. +- **결론: `Slot`의 콜론 메소드.** "원천에 종속된 파생 데이터는 자유 함수 + 생성자가 없고 메소드로만 얻어진다"(State/Observer)는 기존 분류 원칙과 + 같은 모양 — 다만 여기 원천은 Source가 아니라 이미 만들어진 Slot 자신. + Fusion의 `ForPairs`/`ForKeys`/`ForValues` 3분할도 이 재구성으로 통합 + 방향이 자연스러워짐(단일 `:List`가 이미 Slot 메소드 이름공간 안에 + 있으니 여러 진입점을 나열할 이유가 약해짐) — **통합 확정**. +- 이름 후보로 검토됐던 `Render`/`Draw`도 이 재구성으로 더 이상 "타입 + 이름"이 아니라 "메소드 이름" 문제가 됐지만, `List`가 여전히 가장 + 낫다고 판단(`Render`는 quad의 "렌더 주기 없음" 원칙과 메소드 이름으로 + 써도 충돌 소지가 남고, `Draw`는 즉시모드 GUI 뉘앙스) — **`List`로 확정**. + ## 자식으로 넘기는 클래스 스토어 자식에게 내려주는 클래스 스토어는 부모 쪽에서 미리 만들어서 내려보내는 게 @@ -131,13 +543,9 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데 정도 — 설계 방향 자체는 더 이상 열려있지 않음. - "클래스가 슬롯을 받는 방법"(Named Slot 없음)도 확정됨(위 "클래스가 슬롯을 받는 방법" 절 참고). +- **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론, + `isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) — + 위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고. - **여러 Slot이 형제로 섞일 때 순서 보장**은 아직 열려있음(위 "여러 Slot이 섞일 때 순서 보장" 절 참고) — Roblox 단일 백엔드로는 급하지 않음, Slot 코어 로직 구현 시점에 재검토. -- **`add`/`remove`/`clear` CRUD 의미론 자체가 아직 정의 안 됨** — 위 - "개념" 절이 이 세 연산을 뮤터블 메타 배열에 지원되는 것처럼 나열만 - 하고 정확한 동작(예: `add`가 위치를 지정하는지, `remove`가 값 동등성 - 기준인지 참조 기준인지, `clear`가 재마운트 가능한 자리를 남기는지)은 - 정의돼 있지 않음. `research/pre-implementation-audit.md`가 이미 지적한 - 갭이고, 2026-08-07 아홉 번째 세션에서 사용자가 직접 다루기로 보류함 — - Slot 코어 로직 구현 착수 전 반드시 확정 필요. diff --git a/.claude/question.md b/.claude/question.md index 6c09e18..dba4757 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -20,16 +20,12 @@ `archive/batch-rejected.md`, Context(+레이어드 Store) → `archive/ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 남김. -- **키 기반 동적 컬렉션 재조정(유일하게 아직 완전히 열려있음, 최우선)**: - Fusion `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, - React `key` prop에 대응하는 프리미티브가 quad엔 전혀 없음 확인 — - `pre-implementation-audit.md` 1-7번(Slot CRUD 미정의)과 같은 지점이라 - 같이 정의해야 함. **자유 함수**(plain data 또는 State 둘 다 받는 - 폴리모픽 시그니처, State 메소드 프레이밍은 Source 안 쓰는 컴포넌트가 - 못 쓴다는 반례로 철회됨), Slot에 파괴 없이 빼내는 `Extract` 연산 추가 - 필요 — 최종 이름만 미정(아래 "용어 정리" 절에 후보 추가). **사용자가 - "작업 전에 모든 정의를 마치고 싶다"고 명시** — M0 이전 완전 확정 목표. - 상세는 `research/additional-primitives-plan.md`(이제 이 주제 전용). +- **[해소됨, 2026-08-09 세 번째 세션]** 키 기반 동적 컬렉션 재조정 — + `Slot:List(data, updateFn, keyFn?) -> Slot` 콜론 메소드로 완전히 확정 + (자유 함수/새 타입 둘 다 기각, "Slot이 이미 가진 것 위에 새 공개 + 메소드를 안 얹으니 별도 타입일 이유가 없다"는 게 근거). Slot의 + `Extract`/`Add(index)` CRUD와 같이 확정됨, 상세는 `base/slot-plan.md` + "`Slot:List(...)`" 절. - **[해소됨, 2026-08-07 여섯 번째 세션]** Effect/Observer 관계 — Effect는 자유 함수로 확정(`state` 인자를 받으면 내부적으로 `state:Observer(...)`를 조합해 재실행+자동 cleanup 배선, React `useEffect`와 동형). `state:Observer(fn)`도 @@ -80,13 +76,9 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가 기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을 같이 검토할 것. -- **키 기반 동적 컬렉션 재조정 프리미티브 이름(3순위, 2026-08-06 추가)**: - `Keyed`는 타이핑이 어색하고 "Slot을 렌더한다"는 느낌과 안 맞는다는 - 사용자 피드백으로 탈락. 후보: `Render`(가장 직접적이지만 "quad엔 렌더 - 주기가 없다"는 기존 원칙과 이름이 충돌해 보일 수 있음), `Draw`(짧지만 - 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 - 미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션 - 재조정" 절 참고. +- **[해소됨, 2026-08-09 세 번째 세션]** 키 기반 동적 컬렉션 재조정 이름 — + `List`로 확정(`Slot:List(...)` 메소드, `Render`/`Draw`는 기각). 상세는 + `base/slot-plan.md` "`Slot:List(...)`" 절. - **[해소됨, 2026-08-09 세션]** `Bound` — **`canBound(handle): boolean` 탑레벨 함수로 확정**, `canExecute`와 같은 결(raw 필드를 직접 노출하는 대신 predicate 함수로 감쌈). `base/bind-system-plan.md` "이중 바인딩 @@ -178,9 +170,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** `pre-implementation-audit.md` 3-1). **[해소됨]** UI shorthand의 기존 UICorner 매칭 기준도 `base/ui-shorthand-plan.md`에 이미 확정 반영돼 있던 것을 이번에 `pre-implementation-audit.md` 2-11에도 해소 표시로 - 동기화. **아직 실제로 열려있는 건 두 개뿐** — Slot CRUD 의미론 - (`add`/`remove`/`clear`) 미정의(1-7), 우선순위 스캔 동률/매치실패 - 처리(1-3) — `pre-implementation-audit.md` 본문 참고. + 동기화. **[해소됨, 2026-08-09 세 번째 세션]** Slot CRUD 의미론 + (`add`/`remove`/`clear`) 미정의(1-7)/`isMounted` 이중 추적 혼용(1-8) — + `base/slot-plan.md` 참고. **아직 실제로 열려있는 건 하나** — 우선순위 + 스캔 동률/매치실패 처리(1-3) — `pre-implementation-audit.md` 본문 참고. - **[해소됨, 2026-08-08 두 번째 세션]** `Frame { ref }`/`Frame { observer }`처럼 children 배열 숫자 슬롯에 직접 놓는 leaf 값을 매칭·바인드하는 Handler (`(i:number, v=Ref/Observer/PreRef)`)의 패키지 배치 — 원래 제안대로 diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 090e821..939586c 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -4,9 +4,12 @@ **2026-08-07 문서 정리에서 확정/기각된 항목을 분리**: Effect/Blocker → `base/blocker-plan.md`/`base/effect-plan.md`, Batch(lexical) → `archive/ batch-rejected.md`, Context(+레이어드 Store 대안) → `archive/ -context-rejected.md`. 이 문서에는 **아직 완전히 열려있는 것 하나만** 남음 -— 키 기반 동적 컬렉션 재조정. 사용자가 "작업 전에 모든 정의를 마치고 -싶다"고 명시 — M0 전 완전 확정이 목표. +context-rejected.md`. **[2026-08-09 세 번째 세션]** 마지막으로 남아있던 +키 기반 동적 컬렉션 재조정도 `Slot:List(...)` 메소드로 완전히 확정되어 +`base/slot-plan.md`로 승격됨(아래 절은 요약+포인터만 남기고 상세는 그쪽 +참고) — **이 문서에 새로 열려있는 설계 질문은 더 이상 없음**, 아래 표/ +"빈 자리 아닌 것"/"문서화 백로그"/"참고 소스" 절은 배경 리서치 기록으로만 +유지. ## 배경 @@ -29,7 +32,7 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 | 후보 | 판정 | 현재 위치 | |---|---|---| -| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** — 아직 열려있음 | 이 문서(아래) | +| 키 기반 동적 컬렉션 재조정 | **채택, 확정** — `Slot:List(...)` 메소드로 통합 | `base/slot-plan.md`(2026-08-09 세 번째 세션) | | Effect(leaf 죽음에 확정 정리 + `state` 있으면 재실행) | **채택, 확정** — Observer와의 관계도 해소 | `base/effect-plan.md` | | Blocker(값 기반 emit 지연/합치기) | **채택** — Batch의 대안 | `base/blocker-plan.md` | | Batch(함수/코루틴 스코프 lexical block) | **기각** | `archive/batch-rejected.md` | @@ -40,121 +43,21 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 | Error Boundary | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | | Readonly wrapper | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | -## 키 기반 동적 컬렉션 재조정 — 최우선, 설계 진행 중 +## 키 기반 동적 컬렉션 재조정 — 확정, `base/slot-plan.md`로 승격 (2026-08-09 세 번째 세션) -**무엇인가**: 데이터 배열(인벤토리, 리더보드, 채팅로그처럼 삽입/삭제/ -재정렬되는 목록)을 UI로 렌더링할 때, 이전 렌더 결과와 새 데이터를 -**정체성(key) 기준으로 diff**해서 변경분만 생성/갱신/파괴하는 프리미티브. React `key` prop, Vue `v-for :key`, Solid ``, Fusion `ForPairs`/ -`ForKeys`/`ForValues`, Vide `indexes()`/`values()`가 이 층위. `base/ -slot-plan.md`의 `Slot`은 CRUD 껍데기일 뿐 diff 엔진이 아니라서 이 프리미티브가 -quad엔 없음(확인 완료, 근거는 문서 하단 소스 목록 참고). +`ForKeys`/`ForValues`, Vide `indexes()`/`values()`에 대응하는 프리미티브 — +데이터 배열을 정체성(key) 기준으로 diff해서 변경분만 생성/갱신/파괴한다. +**최종 확정 형태는 자유 함수도 새 타입도 아니라 `Slot`의 콜론 메소드** +(`Slot():List(data, updateFn, keyFn?) -> Slot`) — 상세 시그니처/구현 +의사코드/왜 자유 함수·새 타입이 아닌지/`Move` 기반 리오더/`userdata` 기반 +`Source` 관리 위임은 전부 `base/slot-plan.md`의 "`Slot:List(...)`" 절 +참고, 여기서 반복 안 함. -### 왜 "매핑 함수" 직관이 안 통하는가 - -React류는 매 렌더마다 새 가상 트리를 통째로 새로 만들고 reconciler가 -old/new를 diff한다 — 그래서 "그냥 다시 매핑"이 성립한다. quad는 컴포넌트가 -**한 번만 실행**되므로 그 "매 렌더"가 아예 없다. 그래서 이건 매핑 함수가 -아니라 **한 번 설치되면 스스로 diff-and-patch를 도는 Observer 변형**이다 — -전달한 `renderFn`은 새 key가 나타났을 때 딱 한 번만 불리고, 그 이후로는 -값이 바뀌어도 절대 다시 안 불린다. - -### 메커니즘 스케치 - -``` -key가 새로 나타남 → renderFn(key, itemState) 호출, itemSource:Set(초기값), Slot에 삽입 -key가 사라짐 → Slot에서 제거(파괴) -key가 유지, 값만 변경 → renderFn 재호출 없음, itemSource:Set(새값)만 → itemState 구독한 리프만 갱신 -key가 유지, 순서만 변경 → renderFn 재호출 없음, Slot 위치만 조정(파괴/재생성 없음) -``` - -`itemState`는 이 프리미티브 내부 소유의 Source이고, `renderFn`엔 그 State -뷰만 노출한다(Source 자체를 주면 renderFn이 실수로 `:Set()`해서 diff -엔진과 경쟁할 수 있음). - -### 폼 팩터 — 자유 함수로 정정 (State 메소드 프레이밍 철회) - -`state:Keyed(...)`처럼 State의 메소드로 두려던 초안은 "Source를 안 쓰는 -컴포넌트가 접근 못 함" 반례로 철회됨 — 상세 경위는 -`archive/keyed-collection-state-method-rejected.md` 참고. - -재검토 결과 — quad는 이미 **leaf 프로퍼티가 "리터럴 값 또는 State" 둘 다 -받는 폴리모픽 컨벤션**을 갖고 있다(`BackgroundColor3 = someColor`도 -`BackgroundColor3 = someState`도 같은 자리에서 됨, 정적이면 한 번만 세팅, -State면 구독). 이 프리미티브도 같은 컨벤션을 따르는 게 자연스럽다 — -**자유 함수**로 두고 `data` 인자가 plain array/table이든 `State`/ -`Source`든 둘 다 받게 한다. Plain이면 diff 로직 자체가 발동 안 하고 -(다시는 안 바뀌므로 최초 1회 배치만 하면 끝), State/Source면 위 메커니즘이 -동작한다. 이름은 아직 미정이지만 시그니처 형태: - -``` -<이름>(data: {[K]: V} | State<{[K]: V}>, keyFn: (V, K) -> Key, renderFn: (Key, State) -> Child) -> Slot -``` - -### Slot 확장 — `Extract`, 그리고 확정해야 할 소유권 모델 - -순서 변경(파괴 없이 위치만 옮기기)을 처리하려면 Slot에 **"파괴하지 않고 -빼내기"** 연산이 필요하다. 기존 확정 사항(`slot-plan.md`): - -> retract되는 slot은 옮겨지지 않고 그냥 폐기된다 ... React의 portal류로 -> 나중에 옮길 수 있게 하는 것도 검토됐으나 이번 마일스톤에서는 -> 오버엔지니어링으로 판단, 하지 않음. - -이건 **"다른 Slot으로 옮기기"(portal)를 안 한다**는 결정이지 **"같은 Slot -안에서 위치만 바꾸기"**를 막는 결정이 아니다 — 순서 재조정은 후자만 -필요하므로 이 결정과 충돌하지 않는다. - -**사용자가 명확히 한 최종 소유권 모델(확정)**: -- **Slot 자체의 바인딩은 귀속·불가역** — 한 번 마운트되면 그 Slot 컨테이너 - 자체를 다른 곳에 다시 바인드할 수 없음(기존 "재마운트 시 throw"와 동일). -- **Slot 안에 있는 개별 요소의 입출력은 자유** — 넣고 빼고 다시 넣는 것에 - 제약 없음. -- **단, 요소의 `.Parent`를 Slot API를 거치지 않고 직접 만지는 건 UB.** - -제안: - -``` -Slot:Extract(key) -> element -- 파괴 없이 빼냄 -Slot:Add(element, index?) -- 기존 그대로, Extract로 뺀 것도 다시 넣을 수 있음 -``` - -리오더는 `Extract` + `Add(index)` 조합으로 구현(별도 `Move`/`Swap` 원시 -연산은 새로 안 만듦 — 원시 개수를 최소화). 덤으로 이게 -`pre-implementation-audit.md` 1-8번("isMounted가 element 단위와 Slot -컨테이너 단위를 뭉뚱그려 서술됨")을 자연스럽게 푼다 — `Extract`가 있으면 -"element의 mounted 여부는 가변, Slot 컨테이너 자체의 mounted 여부는 -불변"으로 두 개념이 명확히 분리된다. - -### 이름 후보 (확정 아님, 용어 정리 라운드 대상) - -`Keyed`는 탈락 — 타이핑이 어색하고 "Slot을 렌더한다"는 느낌과도 안 맞는다는 -사용자 피드백. 후보: -- `Render(data, keyFn, renderFn) -> Slot` — 가장 직접적("슬롯을 렌더한다"). - 단, quad는 "렌더 주기가 없다"를 아키텍처 원칙으로 강조해왔는데 이 이름만 - "Render"를 쓰면 혼동 가능성 있음. -- `Draw(data, keyFn, renderFn) -> Slot` — 짧음, "Render"와의 충돌은 피하지만 - 즉시모드 GUI(Dear ImGui류) 뉘앙스를 가져올 수 있어 quad의 유지형(retained) - 모델과 어긋나 보일 수 있음. -- `List(data, keyFn, renderFn) -> Slot` — 중립적, 결과가 "리스트"라는 것만 - 전달, 메커니즘을 과다 암시 안 함. - -세 후보 다 트레이드오프가 있어 확정 안 함 — `.claude/question.md`의 용어 -정리 라운드에 후보로 올려둠. - -### 남은 열린 질문 - -- 최종 이름(위 후보 중 또는 새 후보). -- `Extract`/`Add(index)` 조합의 정확한 시그니처(에러 조건: 이미 다른 Slot에 - 있는 요소를 Add하면? 존재 안 하는 key를 Extract하면?). -- Fusion의 `ForPairs`/`ForKeys`/`ForValues` 3종 분리를 안 따르고 - `renderFn(key, itemState)` 1종으로 통합하는 안이 여전히 유력(단순화 - 후보, `pre-implementation-audit.md`의 "단순화 후보" 렌즈와 같은 결) — - 최종 확정 아님. -- **목표: M6(Slot) 착수 전, 가급적 M0 스파이크 이전에 이 전체를 완전히 - 정의**(사용자 명시적 요청) — 이 프리미티브가 Slot 자체의 CRUD 시맨틱 - (`pre-implementation-audit.md` 1-7, 지금 미정)과 얽혀 있어서, Slot을 - 먼저 정하고 나중에 여기를 끼워맞추면 재작업이 날 가능성이 높음. Slot - CRUD 시맨틱을 정의할 때 이 프리미티브의 요구사항을 같이 고려할 것. +이 아래 있던 "왜 매핑 함수 직관이 안 통하는가"/"메커니즘 스케치"/ +"이름 후보"/"남은 열린 질문" 절은 전부 그 문서로 흡수·확정되어 제거함 — +State 메소드로 두려던 초기 폼팩터가 기각된 경위만 여전히 +`archive/keyed-collection-state-method-rejected.md`에 별도 보존. ## 빈 자리 아닌 것으로 확인된 것들 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 7d74850..aa6467c 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -88,7 +88,14 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 - 초심자: Modifier 기본 체이닝+merge 우선순위 규칙 실제 예시 / Slot 기본 개념(children 배열)+클래스가 슬롯 받는 방법(Named Slot 없음) / 마운트된 slot 재마운트 시 즉시 throw - api: Setter가 리터럴/변환 함수 둘 다 받음(→심화: getter 없는 이유) / 필드가 State일 수 있는 4가지 조합 표(→심화: 반응성 유지/끊김 이유) / `mod:UICorner(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) / `:Apply(factory)` 기본 체이닝 관용구(→심화: 언제 `Apply` vs `Overridden`인지 성능 기준) / `:Peek<>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유) - 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) / **왜 `Apply`가 기본이고 `Overridden`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선) -- 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정 +- 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정. + **[2026-08-09 추가]** `Slot:List`의 `prev`/`userdata` 재사용 최적화를 + getting-started에서 "항상 파괴 후 재생성" 단순 버전만 가르치고 나중에 + 최적화 단계에서 별도로 알려줄지, 아니면 Slot이 학습 순서상 core loop + 후반부라 어차피 Source/State를 다 아는 시점이니 처음부터 완전한 형태로 + 한 번에 가르칠지 — 사용자가 직접 제기, 미결. 제 의견은 후자(후반부 + 배치라 단계적으로 나눌 이득이 적어 보임)로 기울지만 확정 아님, 실제 + 콘텐츠 작성 시점에 결정. - skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 ### store-semantics.md / tween-plan.md / ui-shorthand-plan.md diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 476db27..bc5872b 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -198,6 +198,10 @@ eager cleanup을 택했다"는 구체적 위험을 지적하며 "quad는 rbvm ### 1-7. Slot의 `add`/`remove`/`clear` CRUD 의미론이 정의돼 있지 않음 +**[해소됨, 2026-08-09 세 번째 세션]** `base/slot-plan.md`의 "CRUD API +확정" 절에 `Add`/`Remove`/`Extract`/`Clear` 시그니처·에러 조건·재진입성까지 +전부 확정 반영됨(`get`/`set`은 드롭). 아래는 당시 지적 원문, 참고용으로만 남김. + **위치**: `base/slot-plan.md` "개념" 절 — "`add`/`remove`/`clear`/`get`/ `set` 등 뮤터블 연산을 지원하는 메타 배열"이라고만 서술. @@ -214,6 +218,11 @@ eager cleanup을 택했다"는 구체적 위험을 지적하며 "quad는 rbvm ### 1-8. Slot "재마운트 시 throw"가 두 가지 다른 추적 대상을 혼용해서 서술됨 +**[해소됨, 2026-08-09 세 번째 세션]** `base/slot-plan.md`의 "`isMounted` +이중 추적 분리" 절에 Slot 컨테이너(`self._mounted`, dispatch-process 시점 +트리거)와 개별 element(전역 weak-set)를 명시적으로 분리 반영됨. 아래는 +당시 지적 원문, 참고용으로만 남김. + **위치**: `base/slot-plan.md` "핵심 제약: 소유권 귀속과 단일 마운트" + "마운트된 Slot의 재마운트는 즉시 throw" 절. diff --git a/CLAUDE.md b/CLAUDE.md index 4b13402..daf9834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,8 +44,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 - `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. `tween-plan.md`/`existing-instance-bind-plan.md`/`debug-tooling-plan.md`/ `documentation-plan.md`/`documentation-content-map.md`/ - `framework-comparison-findings.md`/`additional-primitives-plan.md`(키 기반 - 동적 컬렉션 재조정만 남음)/`pre-implementation-audit.md`/`v1-compat-plan.md` + `framework-comparison-findings.md`/`additional-primitives-plan.md`(2026-08-09 + 세 번째 세션에 마지막 열린 항목까지 전부 해소, 이제 배경 자료용)/ + `pre-implementation-audit.md`/`v1-compat-plan.md` — 전부 후순위(급한 건 `tween-plan.md` 세부 옵션 정도). 최신 목록·우선순위는 `.claude/README.md`가 소스, 여기서 개수 반복 안 함(과거에 "두 개뿐"이라 적어놨다가 새 문서 추가될 때마다 안 갱신되는 패턴이 반복돼서 아예 안 @@ -1858,3 +1859,293 @@ Modifier만 예외인 건 Modifier가 애초에 dispatch 경로 자체를 안 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, 위 "다음 세션 예고" Slot/키 기반 컬렉션 재조정도 그대로) — 이번 세션은 순수 문서 위생 작업이라 설계 우선순위엔 영향 없음. + +## 2026-08-09 세 번째 세션 — Slot CRUD 완전 확정, 키 기반 동적 컬렉션 +재조정이 `Slot:List(...)` 메소드로 통합·승격 + +위에서 예고된 "다음 세션 주제"(Slot과 키 기반 동적 컬렉션 재조정)를 +실제로 다룬 세션. `pre-implementation-audit.md` 1-7/1-8과 +`research/additional-primitives-plan.md`의 마지막 열린 항목이 전부 +`base/slot-plan.md`에 흡수·확정됐음 — 상세는 그 문서 본문이 소스, +여기는 요지만: + +- **Slot CRUD 최종 확정**: `Add(element, index?)`/`Remove(element)`(제거+파괴)/ + `Extract(element)`(제거, 파괴 안 함)/`Clear()`(전체 `Remove`) — `get`/`set`은 + 드롭(YAGNI). 식별은 항상 element 레퍼런스 기준(인덱스 아님). 에러 조건 + 전부 즉시 `error()`(이미 다른 곳에 마운트된 element를 `Add`, 멤버 아닌 + element를 `Remove`/`Extract`) — fail-fast 톤 유지. 재진입성은 별도 가드 + 불필요(기존 "무한루프 방어 안 함" 원칙 재사용). `Slot()`은 인자 없는 + 빈 생성자로 확정. +- **`isMounted` 이중 추적 분리(1-8 해소)**: Slot 컨테이너 자신은 + `self._mounted`(트리거는 `Dispatch.process`가 이 Slot 객체에 실제로 + 호출된 시점 — 다른 모든 "마운트됨" 판정과 동일하게 dispatch-process + 기준), 개별 element는 전역 weak-set(라이브러리 전역 다중 마운트 금지 + 불변식이라 특정 Slot에 안 묶임). +- **`Extract` 후 portal 범위 — 임의의 다른 Slot으로 자유 이동 확정.** + 기존 "retract되는 slot은 폐기되지 옮겨지지 않는다"는 확정은 **프레임워크가 + store-bind 재실행으로 값을 통째로 갈아치우는 시나리오**에만 해당하고, + 사용자가 명시적으로 `Extract`→`Add` 두 번 호출해서 옮기는 것과는 다른 + 얘기라는 걸 명확히 구분(사용자 확인). +- **키 기반 동적 컬렉션 재조정 — `Slot:List(data, keyFn, renderFn) -> Slot`로 + 확정, 자유 함수/새 타입 둘 다 기각.** 처음엔 `List(...) -> Slot` 자유 + 함수를 검토했으나, "타입 이름=반환 타입"이라는 `Source(default)`류 + 팩토리 컨벤션이 깨진다는 문제를 사용자가 직접 지적 — Source⊇State식 + 구조적 서브타입도 검토했으나 List가 Slot 위에 새 공개 메소드를 안 + 얹으므로(그냥 "자동으로 채워지는 Slot") 별도 타입일 근거가 약해 기각. + 최종적으로 "원천에 종속된 파생 데이터는 메소드로만 얻어진다"(State/ + Observer와 같은 원칙, 여기 원천은 Slot 자신)로 수렴 — `Ref():Callback(fn)` + 체이닝과 같은 패턴. Fusion `ForPairs`/`ForKeys`/`ForValues` 3분할도 + 단일 `:List`로 통합 확정. +- **구현 메커니즘은 전부 기존 프리미티브 재사용, 새 개념 없음** — 사용자가 + "너무 마법같다"고 지적해 의사코드까지 구체화해서 검증: `data:Observer(fn)` + (2026-08-07 확정된 "등록 즉시 1회 실행"), `Source(item)`, 방금 확정한 + Slot CRUD의 비공개(가드 안 거치는) 버전 세 개의 조합일 뿐. `itemSources`/ + `elements`/`order`는 Slot 인스턴스의 평범한 클로저 업밸류(별도 전역 + 저장소 불필요). 리오더는 `Extract`+`Add(index)` 조합, 최소-이동 + 알고리즘 자체는 구현 시점 최적화로 미룸. +- **`renderFn(key, itemState)`의 `itemState`는 내부 `Source`를 그냥 + `State`로 다운캐스트해서 넘김 — 별도 `ReadOnlySource` 타입 안 만듦** + (사용자 확인: "그걸 위해 ReadOnlySource 같은 걸 만들 이유가 있냐 하면 + 아니다, 이미 그게 State다"). 타입 레벨 힌트만, 런타임 강제 없음(`Peek`/ + Modifier UB와 같은 "규율 위반은 방어 안 함" 기조) — 나중에 진짜 + 런타임 강제가 필요해지면 `src:Compute(function(v) return v end)`(항등 + 함수 Compute)로 `:Set` 없는 State를 만드는 가벼운 대안이 있다는 것만 + 메모. +- **백로그, 착수 안 함(연구만) — reconcile의 무조건 `:Set()` 재전파.** + `data`가 테이블 뮤테이션+`:Emit()`으로 오는 경로도 지원해야 해서 이전 + 값과 동등성 비교를 할 방법이 없고, 그래서 값이 실제로 안 바뀐 item도 + 매 재계산마다 재전파됨 — 사용자 판단: "이 재계산 비용은 우리가 핸들해야 + 할 부분은 아닌 것 같다", `Blocker`류 값-동등성 기반 전파 억제도 검토했으나 + "확정 안 하면 이전 값 자체가 없어서 비교가 안 된다"는 근본적 어려움이 + 있어 기술적으로 더 논의해볼 만한 주제로만 `research/ + additional-primitives-plan.md`에 백로깅. +- **`research/additional-primitives-plan.md` 사실상 전부 해소** — 마지막 + 열린 항목(키 기반 컬렉션)까지 없어져서, 이 문서엔 이제 새로 열린 설계 + 질문이 없음(배경 자료로만 유지). `question.md`/`ROADMAP.md`(M6 체크박스)/ + `README.md` 전부 동기화 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — Slot/키 기반 컬렉션 +재조정이 이번 세션에서 완결됐으므로 더 이상 "다음 세션 예고" 대상 아님. +남은 열린 것은 여전히 `question.md`의 `DI`→`D`/`canExecute`→`isAlive`/ +`Brand` 이름, `pre-implementation-audit.md` 1-3(우선순위 스캔 동률 처리), +"여러 Slot이 형제로 섞일 때 순서 보장"(Roblox 단일 백엔드론 급하지 않음) +정도. + +**같은 세션 후속 — quad-roblox 구현 관점에서 재검토, `Move`/`Swap` 공개 +CRUD로 추가(원시 최소화 원칙 뒤집음), `renderFn`에 `indexState` 추가.** +사용자가 "Slot 값 변경을 quad-roblox가 실제로 어떻게 따라가나"를 구체적으로 +캐물으며 세 가지가 드러남: +- **`renderFn(key, itemState) -> element`에 위치 정보가 빠져있었음** — + Roblox는 순서를 `LayoutOrder`로 표현하므로 `renderFn`이 그걸 반응형으로 + 바인딩하려면 위치도 State로 받아야 함. `itemState`와 독립된 + `indexState: State`를 추가(`renderFn(key, itemState, + indexState)`) — 값 변경/위치 변경은 서로 독립 신호라는 게 근거, Slot이 + `LayoutOrder`를 대신 관리해주는 마법은 안 둠. +- **`Extract`+`Add(index)`로 리오더를 구현하면 백엔드에서 진짜 Parent + 조작이 두 번(detach+reattach) 일어난다는 게 드러남** — Roblox + `AncestryChanged` 발화, 잠재적 깜빡임, 불필요한 재바인딩 비용까지 + 딸려올 수 있어 매 `:List` 재계산마다 흔한 케이스치고 과함. + **`Move`(O(n), 배열 splice 의미)/`Swap`(O(1), 순수 페어 교환)을 공개 + CRUD로 추가** — 둘 다 Parent를 안 건드림. `:List` 없이 수동으로 Slot을 + 구성하는 사용자에게 애초에 리오더 수단이 아예 없었다는 것도 같이 + 드러난 공백 — "원시 연산 최소화" 원칙보다 이 두 실사용 공백이 우선한다고 + 판단해 뒤집음(같은 세션 내 정정이라 별도 archive 없이 `slot-plan.md` + 본문에 "원시 최소화 원칙 정정" 절로 직접 반영). +- **base/roblox 패키지 경계에 mount/unmount 둘로는 부족, reposition + 훅이 세 번째로 필요함** — `Dispatch/Slot.luau`/`Handlers/Slot.luau`가 + 이제 "Parent 조작(mount/unmount)"뿐 아니라 "Parent 안 건드리는 재배치 + (reposition, `Move`/`Swap`)"까지 계약해야 함. quad-roblox가 이걸 + `SetSiblingIndex`로 구현할지, `LayoutOrder` 기반 정렬이라 사실상 no-op + 으로 둘지는 구현 선택으로 열어둠. +- **item 값 전파(무조건, 백로그)와 index 전파(실제 변경시만)가 비대칭인 + 이유도 명확해짐** — item 값은 외부 뮤테이션+`Emit()` 경로 때문에 "이전 + 값"을 비교할 방법이 없지만, `:List`가 전적으로 소유하는 `keyIndex`는 + "실제로 위치가 바뀌었는지"를 정확히 알 수 있어 index 쪽엔 같은 문제가 + 없음 — 그래서 index 전파는 처음부터 조건부로 구현. + +전부 `base/slot-plan.md`(CRUD 표, "원시 최소화 원칙 정정" 신규 절, `:List` +구현 스케치·설명 갱신)/`ROADMAP.md`(M6)/`README.md` 반영 완료. `question.md`엔 +새로 열린 항목 없음 — 이번 후속도 순수 확정/구현 세부 명확화. + +**같은 세션 두 번째 후속 — `Swap`을 element 레퍼런스가 아니라 인덱스 +기준으로 정정, "공개 CRUD는 가드+`raw*` 위임" 구조 명문화.** 사용자가 +`Swap(elementA, elementB)`를 바로 잡음 — element 레퍼런스로 받으면 Slot이 +element→index 역방향 맵을 안 갖고 있는 이상 두 element의 현재 위치를 각각 +찾는 데 O(n)씩(총 2n) 들어서, `Swap`이 약속한 O(1)이 그 자리에서 깨짐. +`Move`는 시프트 자체가 O(n)이라 조회 비용이 묻히지만 `Swap`은 조회 비용이 +곧 전체 비용이라 이 차이가 그대로 드러남 — `Slot:Swap(indexA, indexB)`로 +정정(호출부가 이미 "몇 번째와 몇 번째를 바꿀지"를 아는 상황, 예: 드래그 +리오더 UI, 이라는 것도 자연스러움의 근거). 이어서 사용자가 "`Slot:Move` +구현은 결국 락(`_listed`) 확인만 하고 실제 로직은 `rawMove`류에 다 있는 +구조 아니냐"고 확인 요청 — 맞다고 답하며 이걸 여섯 CRUD 전체에 적용되는 +일반 구조로 명문화: `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` 전부 +"`self._listed` 확인 + `raw*` 위임"뿐인 얇은 wrapper, 실제 로직은 전부 +`raw*` 함수 세트 하나에 있고 `:List`의 reconcile도 그 세트를 가드 없이 +직접 호출. 전부 `base/slot-plan.md`/`ROADMAP.md` 반영 완료. + +**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). + +**같은 세션 세 번째 후속 — Slot 요소 타입 제약 신설: `nil` 금지/`None` +허용/핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier) 금지, `Slot()` +제네릭화.** 사용자가 "Slot 안에 뭐가 들어갈 수 있는지 정해진 바 없다"고 +지적하며 시작 — 처음엔 제가 "Ref/Observer/PreRef도 Slot 요소로 허용, +`D.InstSlot = Slot<>`류 백엔드 별칭으로 좁히자"고 제안했으나, +사용자가 바로 반박: Slot이 동적으로 다뤄지는데 그 안에 Ref/Observer가 +들어가면 quad-roblox가 그걸 처리할 방법이 없고(특수 대응을 새로 만들어야 +해서 오버엔지니어링),애초에 왜 필요한지도 불명확하다는 지적 — 검증해보니 +정확히 맞았음: +- `Dispatch/Leaf.luau`가 처리하는 "children 배열에 Ref/Observer/PreRef가 + 직접 놓이는" 케이스는 **그 컴포넌트가 지금 만들고 있는 Instance 자기 + 자신을 가리키는 self-ref 캡처**라(`Frame { PreRef():Callback(fn) }`가 + 그 Frame 자신을 잡음), `inst`가 "지금 생성 중인 바로 그 하나의 Instance"로 + 고정돼 있어야 의미가 성립함. Slot은 특정 컴포넌트 호출 하나에 안 묶이고 + 이미 존재하는 부모에 나중에 독립적으로 붙는 동적 리스트라 이 전제 + 자체가 없음 — Slot 안의 Ref가 "무엇"을 가리켜야 하는지 정의가 안 됨. +- 대체 경로가 이미 있어 능력 손실도 없음 — 특정 child에 ref가 필요하면 + 그 child를 만드는 컴포넌트 호출 자체에 Ref를 넘기면 됨 + (`slot:Add(Frame { Ref = myRef })`). +- 사용자가 직접 대비시킨 반례도 정확함: `State`(Slot 자체가 State의 + 값)은 retract 시 통째로 버려지고 다시 채워지는 굵은 단위 교체라 이미 + 확정된 모델(폐기, 재구성)과 맞지만, Slot **요소 하나하나**로 + Ref/Observer가 들어가는 건 그런 굵은 단위 교체가 아니라 세밀한 CRUD + 대상이라 성격이 다름. +- **결론**: `Modifier` 필드가 핸들러 계층 값을 담으면 즉시 `error`로 + 확정했던 것과 같은 판별 메커니즘(`isRef`/`isPreRef`/`isObserver`/ + `isEffect`/`isModifier` Brand predicate)을 Slot에도 재사용 — 새 + 메커니즘 없이 그대로 막음. 덕분에 `Slot`의 `T`도 "실제로 마운트 + 가능한 최종 값의 타입"으로 단순해짐 — quad-roblox엔 사실상 `T = + Instance` 하나뿐이라 `D.InstSlot = Slot<>`가 사실상 "그" + Slot 타입. `nil`은 기존 배열 파트 `None` 원칙을 그대로 적용해 금지, + `None`은 `:List`의 `renderFn`이 "이 item은 이번엔 스킵"을 표현하는 + 용도로 허용 — `renderFn`의 반환 타입도 `T | None`으로 갱신. +- `Slot()`가 무인자 생성자라 `T` 추론이 안 되므로 tbox 명시적 제네릭 + 적용(`Slot<>()`)이 필요하다는 것도 같이 반영 — 정확한 문법은 + "자식으로 넘기는 클래스 스토어" 절의 기존 tbox 참고 미결과 같은 갈래로 + 묶어 열어둠. + +전부 `base/slot-plan.md`(신규 "요소 타입 제약" 절, CRUD 에러 조건, +`renderFn` 반환 타입) 반영 완료. + +**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). + +**같은 세션 네 번째 후속 — `Slot:List`의 `renderFn`을 "1회 호출"에서 +"매 사이클 호출 + `before` 재사용"으로 재설계, filter/toggle 문제 해결.** +사용자가 두 가지를 연달아 제기: (1) `renderFn`이 `None`을 반환해 "지연 +렌더"를 표현하는 아이디어는 좋지만, State 변경으로 이미 렌더된 필드를 +나중에 다시 지워야 하는 경우(filter)는 기존 "1회만 호출" 모델로 안 풀림. +(2) filter/sort를 Slot에서 어떻게 구현할지가 문제 — 흔한 회피책인 +"`Visible`만 토글"은 필터링된 item도 여전히 완전히 살아있는 Instance로 +남겨서(애니메이션/이벤트 연결 계속 돎) 200개+ 리스트에서 lazy하지 않다는 +실질적 비용이 됨. + +**해법 — 사용자가 직접 제시**: `renderFn(itemState, before: inst?): inst?` +모양으로 바꿔 **매 reconcile 사이클마다 호출**하되, 이전에 마운트된 +element(`before`, 없으면 `nil`)를 받아서 `if before then return before +end`(바꿀 거 없으면 그대로 반환, 값 갱신은 이미 물려있는 반응형 바인딩이 +자동으로 함)로 저비용 재사용 경로를 만듦 — filter 탈락 시엔 `nil` 반환으로 +**진짜 파괴**(Visible 토글 아님). 편의상 `renderFn`이 raw `nil`을 +던지는 게(Lua에서 자연스러운 관용구) `None`보다 편하다는 것도 사용자가 +지적 — 검토 결과 `renderFn`의 반환값은 raw Slot 요소로 직접 들어가는 +게 아니라 `:List`의 reconcile이 해석만 하는 것이라, `nil`을 받아도 위 +"요소 타입 제약"(raw Slot 요소는 `nil` 금지)과 전혀 안 부딪힘 — `nil`/ +`None` 둘 다 "스킵" 신호로 동일하게 받아들이기로 정리. + +**부수적으로 드러난 것 — "이전 상태를 다음 렌더에 어떻게 넘기냐" 문제는 +이미 해소돼 있었음.** 사용자가 "item이 보통 plain table이라 매 렌더마다 +Source/Store를 새로 안 만들려면 이전 상태를 어딘가 저장해야 하는데 그게 +어렵다"고 우려했으나, 확인해보니 `itemSources[key]`/`indexSources[key]`가 +`renderFn` 호출 여부와 무관하게 **처음부터 `:List` 자신이 계속 소유**하고 +있어서(원래 설계 그대로) — `renderFn`이 매 사이클 불려도 이 부분은 전혀 +안 바뀜, item이 filter 탈락 후 재등장해 Instance가 파괴됐다 새로 만들어져도 +반응형 Source는 안 끊기고 그대로 이어짐. 이 부분은 재설계가 아니라 기존 +설계가 이미 답이었다는 걸 확인한 것. + +**sort는 이번 재설계와 무관** — 호출부가 `data` 순서를 바꾸면 기존 +`keyIndex`/`Move` 메커니즘이 이미 처리, 새로 손댈 것 없음(사용자가 filter와 +같이 물었던 것 중 이건 원래도 문제 없었음). + +전부 `base/slot-plan.md`(요소 타입 제약 절 "None 허용" → "nil/None 둘 다 +금지"로 정정, `:List`의 `renderFn` 시그니처·구현 스케치·"왜 매 사이클 +호출로 바뀌었는가" 신규 절)/`ROADMAP.md`(M6)/`README.md` 반영 완료. + +**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). + +**같은 세션 다섯 번째 후속 — `renderFn` → `updateFn` 개명, `:List`가 +`Source` 생성을 그만두고 `userdata`로 그 권한을 통째로 넘김.** 사용자가 +"`renderFn`이 아니라 `updateFn`이 맞고, `itemState`도 `:List`가 강제로 +만들지 말고 원문 item + `userdata: UD?` + `prev: T?`를 주는 게 낫다"고 +제안 — 검토 후 채택, 근거: +- **`itemState`/`indexState`를 `:List`가 강제로 만드는 건 불필요한 강요였음** + — 반응형이 필요 없는 단순한 행까지 전부 `Source` 생성 비용을 지게 + 했음. `userdata`로 권한을 넘기면 필요한 item만 자기 `Source`를 만들어 + `userdata`에 담고, 나머지는 매번 raw `item`에서 다시 계산해도 됨 — + `:List`가 미리 정할 이유가 없는 선택. +- **"이전 상태를 다음 호출에 넘기는" 문제, 원래 걱정했던 것과 달리 + `userdata`라는 명시적 채널로 완전히 해소됨** — item이 plain table이라 + 매번 `Source`를 새로 안 만들려면 어딘가 저장해야 한다는 우려가 있었는데, + `userdata`가 정확히 그 저장소. +- **`prev`(구 `before`)와 `userdata`가 원래 비일관적이었음** — 사용자가 + 직접 지적: 하나(`prev`)는 `:List`가 자동 관리하는데 다른 + 하나(`userdata`)만 수동 반환을 요구했음. 해법은 **둘 사이 커플링을 + 완전히 제거** — `result`가 `nil`이라고 `:List`가 `userdata`를 자동으로 + 안 지움, 그대로 기록만 함. 흔한 경우(둘 다 리셋)는 `return nil` 하나로 + Lua가 나머지 반환 슬롯을 알아서 `nil`로 채워주고, "파괴하되 캐시는 + 남기고 싶다"는 정당한 패턴은 `return nil, ud`로 명시적으로 표현 + 가능해짐 — 이전 설계(result nil이면 userdata 자동 삭제)로는 이 패턴이 + 원천 봉쇄돼 있었음. +- **제가 놓칠 뻔한 버그를 사용자와의 논의 과정에서 직접 잡음**: `userdata`가 + 이제 `mounted`(실제 element)보다 오래 살 수 있게 되므로, 정리 루프가 + `pairs(mounted)`만 순회하면 "필터 탈락 상태(mounted=nil)로 `userdata`만 + 살아있던 key가 `data`에서 완전히 사라지는" 케이스를 못 잡고 새서 + — 직전 사이클의 전체 key 집합(`keyIndex`, 매 사이클 모든 key에 대해 + 채워짐)을 순회하도록 정정. +- **부수 효과 — "item 값 무조건 재전파" 백로그가 사라짐**: `:List`가 + 더 이상 `Source`를 안 만드므로 그 문제 자체가 `:List` 소관이 아니게 + 됨, `updateFn` 작성자의 선택으로 넘어감. +- `userdata = userdata or {}`류 lazy-init 관용구가 `UD`가 자유 제네릭인 + 채로 Luau 타입 시스템에서 잘 좁혀지는지는 실측 필요 항목으로 명시적으로 + 남김(사용자가 직접 이 불확실성을 짚음) — M0/M6 착수 시 확인. + +전부 `base/slot-plan.md`(`:List` 절 전면 재작성 — `updateFn` 시그니처/구현/ +"왜 `Source`를 `:List`가 안 만드는가" 신규 절)/`ROADMAP.md`(M6)/ +`README.md` 반영 완료. + +**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). + +**같은 세션 여섯 번째 후속 — `keyFn` 선택 인자화(파라미터 순서 정정), +`userdata` cleanup 훅 검토 후 기각·GC-native 제약 명문화, 문서화 순서 +질문은 백로그로 이관.** 사용자가 세 가지를 짧게 제기: + +1. **`Slot:List(data, keyFn, updateFn)` → `Slot:List(data, updateFn, + keyFn?)`로 파라미터 순서 정정, `keyFn` 선택 인자화.** 실사용 대부분 + (사용자 추정 80%)이 item identity 추적 없이 순번을 key로 써도 충분한 + 단순 목록이라 매번 `keyFn`을 명시하게 하는 게 불필요한 보일러플레이트 — + 생략 시 `function(item, index) return index end` 기본값. tradeoff(중간 + 삽입/삭제 시 그 뒤 항목들이 "다른 item인데 같은 key"로 오인돼 캐스케이드 + 갱신 — identity 보존 없음, 파괴/재생성 자체는 없음)는 React `key` 생략 + 시 index 기본값 등 업계 흔한 관행과 같은 결이라 새로 설명할 개념 아님. +2. **`updateFn(item?, ...)`로 바꿔 최종 제거 시 "정리용 1회 추가 호출"을 + 주는 안 — 검토 후 기각, 사용자가 직접 반례를 찾음.** 이 훅은 `data`에서 + key가 빠져 `reconcile`이 다시 도는 정상 경로에서만 발화하는데, **Slot을 + 담은 부모 Instance 자체가 `Destroy`되는(가장 흔한) 경로는 + `reconcile`이 다시 안 돌아서 이 훅이 전혀 안 불림** — 절반만 동작하는 + 정리 메커니즘은 없는 것보다 위험(사용자가 "정리가 보장된다"고 오해하고 + `Subscribe`류를 `userdata`에 넣었다가 Destroy 경로에서 조용히 샘). + `retract`가 Destroy 시 절대 안 불린다는 기존 원칙(`lifecycle-pattern.md` + "quad는 라이프사이클 중간에 있지 않다")과 정확히 같은 이유로 기각. + **대신 `userdata`엔 GC-native 값만 담고, `:Subscribe()`한 Observer류처럼 + 명시적 cleanup이 필요한 값을 담는 건 UB로 명문화** — quad 전역 + GC-native 원칙을 `:List`라는 구체 지점에 그대로 적용한 것뿐, 새 원칙 + 아님. +3. **문서화 순서(getting-started에서 단순 버전만 가르치고 나중에 + `prev`/`userdata` 최적화를 알려줄지, 아니면 Slot이 학습 순서상 후반부라 + 처음부터 완전한 형태로 가르칠지)는 결정 안 함** — `research/ + documentation-content-map.md`의 modifier/slot 절에 백로그로 추가, + 제 의견(후자 쪽으로 기욺)만 메모, 실제 콘텐츠 작성 시점 결정 사항이라 + 지금 확정 안 함. + +전부 `base/slot-plan.md`(`:List` 시그니처/코드 재정렬, `keyFn` 기본값 +설명, "`userdata`의 생명주기 제약" 신규 절)/`ROADMAP.md`(M6)/`README.md`/ +`research/documentation-content-map.md` 반영 완료. + +**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). diff --git a/ROADMAP.md b/ROADMAP.md index 0eb15a5..cc00409 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -170,14 +170,44 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] "여러 Slot이 형제로 섞일 때 순서 보장" 열린 질문 확인(`slot-plan.md`) — Roblox 단일 백엔드로는 급하지 않으면 스킵하고 진행 가능 -- [ ] **Slot의 `add`/`remove`/`clear` CRUD 의미론 확정 — 착수 전 필수.** - `research/pre-implementation-audit.md` 우선순위1이 지적한 갭, 아직 - 미해결(2026-08-07 아홉 번째 세션에서 "사용자가 다음 세션에서 직접 - 다루기로 보류"로 확인, 2026-08-09 세션 말미에도 다음 세션 예고로 - 다시 지목됨). "재마운트 시 즉시 throw"가 개별 element 기준인지 Slot - 컨테이너 전체 기준인지도 이 논의에서 같이 확정할 것. -- [ ] base `Dispatch/Slot.luau`(추상 재조정) + quad-roblox `Handlers/Slot.luau` - (실제 Parent 조작) +- [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론 + 확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지 + 전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 + throw"도 `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 + 기준이 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절). + `Move(element, newIndex)`(O(n))/`Swap(indexA, indexB)`(O(1), element + 아닌 인덱스 — element면 위치 조회에 2n 들어 O(1) 약속이 깨짐)은 + 리오더 전용, Parent를 안 건드림. 공개 6개 메소드 전부 "가드 확인 + + `raw*` 위임" 얇은 wrapper. base/roblox 경계에 mount/unmount 외 + reposition 훅 추가됨. **`Slot()` 제네릭화, 요소 타입 제약 확정** + — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 + `T`만), 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier)은 + self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(`Modifier` + 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = Slot<>`가 + quad-roblox의 사실상 유일한 Slot 타입. +- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, + `keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity + 보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프). + `updateFn(item, index, userdata: UD?, prev: T?): (T|nil, UD?)`가 + **매 reconcile 사이클마다 호출**(filter/toggle 지원 — 첫 반환값 + `nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은 + 문제 회피), `prev` 그대로 반환하면 저비용 재사용 경로. `:List`가 + `Source`를 대신 안 만듦 — item/index를 반응형으로 감쌀지는 + `updateFn`이 `userdata`에 직접 관리(반환값 두 개는 서로 독립, + `result`가 `nil`이어도 `userdata`는 명시적으로 반환 안 하는 한 안 + 지워짐). 정리 루프는 `mounted`가 아니라 직전 사이클 `keyIndex` + 전체를 순회해야 함(`userdata`만 살아있는 채로 key가 완전히 사라지는 + 케이스 커버). `userdata = userdata or {}` lazy-init 패턴이 Luau + 제네릭에서 잘 좁혀지는지 실측 필요. **`userdata`는 GC-native 값만 + 허용, `:Subscribe()`한 Observer류 명시적 cleanup 필요한 값은 UB** — + `item`을 nilable로 바꿔 최종 제거 시 정리 훅을 한 번 더 부르는 안은 + 기각(Slot 부모 자체가 Destroy되는 경로에선 이 훅이 전혀 안 불려서 + 절반만 동작, `retract`가 Destroy 시 안 불리는 것과 같은 이유). + (2026-08-09 세 번째 세션 확정, + `base/slot-plan.md` "`Slot:List(...)`" 절) 구현 +- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) + + quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition — + `SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택) ## M7 — Modifier From b9cfe8e99dba16f2f6cde062b83206ac08188863 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 20:58:56 +0900 Subject: [PATCH 54/62] =?UTF-8?q?decide(base):=20Slot=20=ED=98=95=EC=A0=9C?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EB=B3=B4=EC=9E=A5(Length/Offset),=20bi?= =?UTF-8?q?ndLifetime/unbindLifetime=EA=B3=BC=20canBound=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EB=B0=94=EC=9D=B8=EB=94=A9=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 여러 Slot이 형제로 섞일 때 LayoutOrder 순서 보장 메커니즘을 Dispatch.setLength/setOffsetSource 누적합+리액티브 바인딩으로 확정하고 base/slot-plan.md의 관련 열린 질문을 해소. 이 과정에서 발견된 라이프사이클 게이트의 실제 모양(진짜 독립 경로는 :Subscribe()/bindLifetime 둘뿐이고 leaf 부착은 bindLifetime 호출 그 자체라는 점, canBound의 내부 플래그가 canExecute가 보는 .Subscribed와 동일 필드라는 점)을 반영해 이중 바인딩 금지 규칙과 기존 StoreBind 예제를 정정. --- .claude/base/bind-system-plan.md | 311 +++++++++++++++++++++++++----- .claude/base/effect-plan.md | 11 +- .claude/base/lifecycle-pattern.md | 52 ++++- .claude/base/slot-plan.md | 48 +++-- .claude/question.md | 16 +- CLAUDE.md | 110 +++++++++++ ROADMAP.md | 51 +++-- 7 files changed, 510 insertions(+), 89 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 40a23e6..9b847db 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -407,6 +407,142 @@ end 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 새로 설계할 필요 없음. +### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) + +**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, +2026-08-04 신설)**: `Frame { Slot1, Element, Slot2 }`처럼 Slot과 정적 +자식이 형제로 섞일 때, Slot1의 동적 개수가 바뀌어도 "Slot1 전체는 항상 +Element보다 앞, Slot2보다 앞"이라는 저작 순서가 유지돼야 함. Slot2가 +자기 순서를 정하려고 "Slot1이 지금 몇 개인지"를 직접 세는 방식은 +Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드 의존을 +만들어서 막다른 길. + +**해법의 핵심 전환**: 절대 위치를 계산해서 전파하는 게 아니라, **각 +구조적 위치(자리 자체는 저작 시점에 고정)가 자기 앞의 형제들이 지금까지 +기여한 개수의 누적합만 알면 됨** — Roblox는 `LayoutOrder`/`ZIndex`가 +`Instance.Parent` 배열의 물리적 순서와 완전히 분리된 정수 프로퍼티라, +이 누적합을 그 프로퍼티에 반응형으로 바인딩하기만 하면 별도 배선이 +필요 없음(이미 있는 store-bind 재실행 패턴 재사용). + +**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**: + +```lua +Dispatch.setLength(inst, i, len: number | State) +Dispatch.setOffsetSource(inst, i, offset: Source | None) +``` + +- **`setLength`**: 이 위치(array part의 number 인덱스 `i`)가 지금 몇 개의 + 실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수 + `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, + 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 + store-bind 핸들러가 값이 바뀔 때마다 다시 호출. +- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 + **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 + 하고, `recompute`가 그 자리에 값을 `:Set()`함. Handler는 이 **같은** + Source 객체를 자기 원소(들)의 `LayoutOrder` 바인딩에 그대로 씀 + (`localIndex:With(offset):Compute(function(i,o) return i+o end)`을 + `LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind + 재실행 메커니즘이 알아서 다시 씀 — 새 push/observer 시스템 불필요). + **실제 마운트를 하지 않는 위치(Ref/PreRef 등)는 `None`을 등록** — 순서 + 계산에 참여할 게 없다는 명시적 선언. + +**둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB +(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — +이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 +이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. + +**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 +`inst`의 array part 크기 `N`만큼) — `Relate(parentInst)`에 lazy 생성. + +**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: + +```lua +local function recompute(inst, bk) + local sum = 0 + for i = 1, bk.N do + local v = bk.lengthList[i] + sum += (isState(v) and v:Get() or v) + local offset = bk.sourceList[i] + if offset and offset:Get() ~= sum then -- 실제로 다를 때만 Set + offset:Set(sum) + end + end +end +``` + +전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 +길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 +캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, +`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 +일어나게 막음. + +**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), +`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: + +```lua +function Dispatch.setLength(inst, i, len) + local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 + + local oldObserver = bk.observers[i] + if oldObserver then + unbindLifetime(inst, oldObserver) -- gchold 내부 구조 몰라도 됨 + bk.observers[i] = nil + end + + bk.lengthList[i] = len + + if isState(len) then + local observer = len:Observer(function() + recompute(inst, bk) + end) + bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님 + bk.observers[i] = observer + end + + recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) +end +``` + +`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 +본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 +같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 +끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, +`inst` 생명주기에 자동 귀속)를 충족. + +**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 +실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: +Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder +갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제 +트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set` +자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위 +`Get` 가드와 같은 원칙을 호출부에서도 적용). + +**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 +한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 +도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. + +**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ +`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: +DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, +`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 — +quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고, +`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느 +물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히 +동일, backend Handler의 "무엇을 하는가"만 다름. + +**`Slot.Length`와 `Slot.Offset`은 별개(사용자 질문으로 명시화)**: +`Length`는 Slot이 스스로 노출하는 순수 출력값(지금 실제로 마운트된 +개수) — "n개 검색됨" 같은 UI에 그대로 써도 되고, 동시에 위 `setLength`가 +읽는 바로 그 값(하나의 State가 두 용도를 겸함). `:List`가 filter 탈락을 +실제 `Remove`로 처리하도록 이미 확정해둔 덕에(Visible 토글 아님) `Length`는 +자동으로 "실제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰는 경우엔 +`Length`가 그걸 못 잡는 게 맞고, 그건 별도 State로 계산해야 하는 사용자 +몫. `Offset`은 Dispatch가 `setOffsetSource`로 등록받아 `recompute`가 +채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source`. + +`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로 +해소됨 — 상세는 그 문서 참고. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -432,25 +568,34 @@ local observer = state:Observer(function() Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리 Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push) end) -observer:Subscribe() -relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 들고 있어야 함 +bindLifetime(inst, observer) +relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르려면 들고 있어야 함 ``` -- children-array leaf 부착(`Frame { observer }`)이 **아니라** `:Subscribe()`/ - `:Unsubscribe()` 경로를 씀 — 이 Observer는 핸들러 내부 배관이라 사용자가 - 보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer - 핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 - `:Subscribe()`가 유일한 선택). -- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐 — 위임 대상까지 - 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 밑에 위임된 - 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 핸들러의 `retract`는 - 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위 "이벤트도 - store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 - 이유(새 디스패치 메커니즘 없이 기존 계약만 구현). +**[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가 +아니라 `bindLifetime`/`unbindLifetime`을 씀 — 원래 이 절이 "leaf가 +아니니 `:Subscribe()`가 유일한 선택"이라고 적어뒀던 게 틀림.** `:Subscribe()`/ +`:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 +최상위에 두는 디버그 print용 등)를 위한 전역 GC 방지 테이블 전용 — +"leaf가 아니면 `:Subscribe()`"가 아니라 "**`inst`에 안 묶이면** +`:Subscribe()`, `inst`에 묶이면(leaf든 이런 핸들러 내부 배관이든) +`bindLifetime`"이 실제 기준. 이 Observer는 처음부터 `inst`(그리고 그 +자식 프로퍼티 `k`)에 묶여있는 존재라 `bindLifetime`이 맞음 — 위 "이중 +바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라, +`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임). + +- **`retract`가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — 위임 + 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 + 밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 + 핸들러의 `retract`는 정확히 자기 자신의 자원(Observer)만 정리하면 + 끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 + 비용이 낮다"고 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 + 계약만 구현). - **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 - 특별 취급됨). + 특별 취급됨). `bindLifetime`도 이 `.Subscribed` 필드를 그대로 + 세팅/해제하므로(위 "이중 바인딩 금지" 절 참고) 이 게이팅은 그대로 유효. - Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). @@ -1163,7 +1308,10 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue 로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table -기반 자동 추적이 적용 안 됨. +기반 자동 추적이 적용 안 됨. **[용어 정정, 2026-08-09 여섯 번째 세션]** +여기서 "weak-table 기반 자동 추적"이라 부른 것이 나중에 정식으로 +`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘 +두 개가 아니라 같은 것의 명명 전/후 표현. **해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클 @@ -1194,9 +1342,13 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 - **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. -- **`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀** — Instance가 - 파괴되기 전에 수동으로 조기 해제하고 싶을 때도 같은 메소드 하나로 - 충분, 별도 API 안 만듦. +- **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 + 해제는 `unbindLifetime(inst, value)`가 담당, `:Unsubscribe()`는 + 전역 강참조 레지스트리 경로 전용으로 남음.** `inst`를 모르는 + `:Unsubscribe()`가 `bindLifetime`이 어느 `inst`에 등록했는지 찾아낼 + 방법이 없어서(레지스트리가 `inst`별로 나뉘어 있음) 하나로 통합할 수 + 없음 — 위 "이중 바인딩 금지" 절의 정정 참고. - **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상** — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 @@ -1210,15 +1362,33 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능. -### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정) +### 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정) **규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 -딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 -(위 weak table 경로) `:Subscribe()`로 수동 등록되거나(위 강참조 -레지스트리 경로), 둘 중 하나만. **둘 다 동시에 걸리는 건 UB로 확정** — -이미 leaf에 부착된 핸들을 다시 `:Subscribe()`하는 것도, 이미 -`:Subscribe()`한 핸들을 children 배열에 놓아 leaf로도 부착시키는 것도 -둘 다 금지. +딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에 +등록되거나(위 절), `bindLifetime(inst, value)`로 특정 `inst`에 종속되거나 +(아래 "`bindLifetime`도 같은 게이트를 공유" 절) — **이 둘 중 하나만**. + +**[정정, 2026-08-09 여섯 번째 세션] "leaf 부착"은 세 번째 독립 경로가 +아니라 `bindLifetime`을 호출하는 것 그 자체다.** `Frame { observer }`처럼 +children 배열에 Observer를 직접 놓으면, `Dispatch/Leaf.luau`가 이걸 +매치해 내부적으로 `bindLifetime(inst, observer)`를 호출 — "children +배열에 놓여 leaf에 자동 부착"과 "`bindLifetime`으로 특정 `inst`에 +종속"은 **같은 동작**이라 서로 배타적일 수 없음(둘 다 하는 게 아니라 +leaf 부착이 곧 `bindLifetime` 호출 방식 중 하나일 뿐). 그래서 실제 +상호 배타는 "전역 소유(`:Subscribe()`)" vs "특정 `inst` 소유 +(`bindLifetime`, 직접 호출이든 leaf 부착을 통한 호출이든)"라는 +**2-way**로 정정 — 위 "Observer의 `:Subscribe()`/`:Unsubscribe()`" 절이 +leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifetime`이 +정식 이름을 얻기 전(2026-08-06 후속 세션) 표현이라 지금은 같은 것을 +가리킴 — 별도 메커니즘 두 개가 있던 게 아니었음. + +**둘 이상 동시에 걸리는 건 UB로 확정** — 이미 한 경로로 바인딩된 핸들을 +다른 경로로 또 바인딩하는 건 금지(leaf로 이미 부착된 걸 `:Subscribe()` +하는 것, 또는 그 반대). 같은 값을 `bindLifetime`으로 두 번(leaf 부착 +한 번 + 직접 호출 한 번, 또는 leaf로 두 Instance에 부착) 등록하려는 +것도 걸림 — 이건 "leaf vs bindLifetime 충돌"이 아니라 "같은 단일 +메커니즘을 중복 호출"하는 것이라 자연히 같은 게이트가 잡아줌. **UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 @@ -1236,9 +1406,10 @@ raw 필드(`self.Bound`)를 직접 보여주지 않고 같은 스타일의 탑 "코드 스타일 — 네이밍 케이싱" 절과 같은 기준): ```lua --- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인 +-- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) +-- — 둘 다 진입 전 동일하게 확인 if not canBound(self) then - error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음") + error("Observer/Effect가 이미 다른 경로로 바인딩됨 — :Subscribe()와 bindLifetime(leaf 부착 포함)은 동시에 쓸 수 없음") end -- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐) ``` @@ -1248,26 +1419,80 @@ end 구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만, 공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만 바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는 - 안 바뀜. + 안 바뀜. **이 내부 플래그는 새 필드가 아니라 `canExecute`가 이미 보는 + `.Subscribed` 필드 그 자체(2026-08-09 여섯 번째 세션 명시)** — + `:Subscribe()`뿐 아니라 `bindLifetime`도(Observer/Effect 값에 한해) + 이 필드를 `true`로 세팅, `:Unsubscribe()`/`unbindLifetime` 둘 다 + `false`로 되돌림 — 그래야 `bindLifetime`으로 등록된 Observer도 + `canExecute`가 정상적으로 "살아있음"으로 인식함(필드를 둘로 나누면 + `bindLifetime`으로만 등록된 Observer가 `canExecute`에서 항상 + `false`로 오판됨). - 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 대칭적으로 막힘. -- **`:Unsubscribe()`는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 - 뜻으로 통일** — 바인딩이 어느 경로로 세워졌든, `:Unsubscribe()` 한 - 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 강참조 등록이든)을 - 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 자동(리프) 케이스에도 - 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 별개로, "단일 - 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. -- **Effect도 동일 규칙 적용** — 내부적으로 Observer를 조합하는 경우든 - `state` 없는 경우든 같은 `canBound` 게이트를 그대로 재사용 - (`base/effect-plan.md`). 이전에 그 문서에 적어뒀던 "leaf 부착과 - `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 규칙으로 - 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 하는 - 조합이었음. +- **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` + (leaf 부착 포함) 경로는 `unbindLifetime(inst, value)`로 해제** — + 둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이 + `unbindLifetime`도 대칭적으로 부르는 책임을 짐 — `inst`를 모르는 + `:Unsubscribe()`가 대신 처리할 수 없는 정보라서). leaf 부착으로 + 세워진 바인딩의 실제 해제도(예: Instance 파괴 전 조기 해제하고 싶을 + 때) 결국 `unbindLifetime`이 담당 — 위 "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀" 절의 서술은 leaf 부착이 별도 메커니즘이라고 + 전제했던 것이라 **이 정정으로 대체**(`:Unsubscribe()`가 아니라 + `unbindLifetime`이 leaf 해제의 실제 통로). +- **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로 + 내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은 + `canBound` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect + 자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이 + 이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf + 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 + 규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 + 하는 조합이었음. - **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children - 배열에 놓았다면 그걸 다시 `:Subscribe()`하지 말 것, 반대도 마찬가지 — - 두 경로를 동시에 쓰고 싶으면 각각 독립된 새 `Effect(...)`/ - `state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + 배열에 놓았다면(=`bindLifetime`으로 등록된 것) 그걸 다시 + `:Subscribe()`하거나 다른 Instance에 또 leaf로 놓지 말 것, 반대도 + 마찬가지 — 여러 경로를 동시에 쓰고 싶으면 각각 독립된 새 + `Effect(...)`/`state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + +### `bindLifetime`이 이 게이트의 두 번째(이자 leaf 부착이 실제로 쓰는) 진입점이다 (2026-08-09 여섯 번째 세션) + +`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때 +쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은 +`canBound` 게이트를 확인** — Observer/Effect 값을 `bindLifetime`할 때도 +진입 전 `canBound(value)`를 확인하고, 통과하면 바인딩됨으로 표시. +**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** — +`Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면 +그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용" +바인딩 로직이 따로 있는 게 아님. 그래서 **실제 상호 배타는 `:Subscribe()` +(전역 강참조 레지스트리)와 `bindLifetime`(inst별 gchold, 직접 호출이든 +leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만들 이유가 +없음, 기존 게이트에 진입점 하나(`bindLifetime`, leaf 부착이 그 특수 +사례)만 추가. + +```lua +function bindLifetime(inst, value) + local isOE = isObserver(value) or isEffect(value) + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + ... -- gchold 등록(base/lifecycle-pattern.md) + if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용 +end + +function unbindLifetime(inst, value) + ... -- gchold 해제 + if isObserver(value) or isEffect(value) then value.Subscribed = false end +end +``` + +- **비-Observer/Effect 값(예: Tween 내부에 쓰는 평범한 클로저)은 이 게이트 + 자체가 안 적용됨** — `canBound`는 `.Subscribed`류 필드가 있는 Observer/ + Effect 전용 predicate라, 그 외 값은 `bindLifetime`이 그냥 통과시킴(leaf/ + `:Subscribe()` 경로 자체가 성립 안 하는 값들이라 충돌 대상이 없음). +- Observer/Effect가 `bindLifetime`으로 바인딩된 뒤엔 `canBound`가 + `false`를 반환하므로, 그 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 + 기존 두 진입점의 기존 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 + 없이 이미 성립. **quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는 `archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index ab75825..8d74af6 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -108,10 +108,13 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` 플래그, 2026-08-09 세션에서 이름 확정)은 - `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. leaf 부착 - **후** `:Unsubscribe()`로 조기 해제하는 것(위 "Observer의 - `:Unsubscribe()`는 자동 케이스에도 동일하게 씀" 패턴)은 여전히 정상 — - 금지되는 건 leaf 부착과 `:Subscribe()`를 **같이** 쓰는 것뿐. + `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 여섯 번째 세션, 이전 미해결 절 대체) diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index f4ab998..90d82be 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -130,19 +130,35 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). -### `bindLifetime`/`canExecute` — 확정(2026-08-08 세션) +### `bindLifetime`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션, +`unbindLifetime`은 2026-08-09 세션 추가) **탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ `Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ -`canExecute`는 `isState`/`isObserver`처럼 핸들러 작성자가 직접 호출하는 -**1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 감싸면 안 됨 — -`LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 평평한 함수: +`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 핸들러 작성자가 +직접 호출하는 **1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 +감싸면 안 됨 — `LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 +평평한 함수: ```lua bindLifetime(inst: any, value: any): () +unbindLifetime(inst: any, value: any): () canExecute(inst: any, value: any): boolean ``` +**`unbindLifetime` 추가 이유(2026-08-09 세션, `bind-system-plan.md`의 +"Length/Offset" 논의에서 파생)**: `Dispatch.setLength`(같은 위치에 새 +`State`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함, +`State` 교체가 대표 사례)처럼 **`inst` 전체 생명주기보다 먼저, +특정 값 하나만 콜백/구독을 끊어야 하는 경우**가 실제로 생김 — +`bindLifetime`만 있으면 그 호출부가 gchold의 내부 저장 구조(배열이든 +`value`를 키로 쓰는 테이블이든)를 직접 알아야만 특정 항목을 지울 수 +있어서 캡슐화가 깨짐. `unbindLifetime(inst, value)`을 짝으로 추가하면 +호출부는 내부 구조를 몰라도 됨 — 구현이 쉬운 이유도 여기 있음(아래 +스케치처럼 gchold를 `value`를 키로 쓰는 테이블로 두면 `gchold[value] = +nil` 한 줄). 안 걸려있던 값에 불러도 안전한 no-op(`:Unsubscribe()`류 +기존 관례와 동일). + base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가 `BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute` 관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md`의 @@ -156,11 +172,18 @@ 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 = {} + local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로 relate:SetStrong(inst, GCHOLD, gchold) gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존 @@ -168,14 +191,23 @@ function bindLifetime(inst, value) relate:SetStrong(inst, GCCONN, gcconn) end local gchold = relate:GetStrong(inst, GCHOLD) - table.insert(gchold, value) -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC + 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는 자기 바인딩 경로(leaf 부착 또는 :Subscribe())의 - -- 생존 여부를 스스로 알고 있음 — inst가 살아있어도 이 값이 먼저 죽어 - -- 있을 수 있으므로(예: retract가 :Unsubscribe()만 하고 inst는 안 죽음) - -- 반드시 먼저 확인. + -- Observer/Effect는 자기 바인딩 경로(bindLifetime=leaf 부착 포함, + -- 또는 :Subscribe())의 생존 여부를 스스로 알고 있음 — inst가 살아있어도 + -- 이 값이 먼저 죽어 있을 수 있으므로(예: retract가 unbindLifetime만 + -- 하고 inst는 안 죽음) 반드시 먼저 확인. if (isObserver(value) or isEffect(value)) and not value.Subscribed then return false end diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 0ffa9a5..134f56c 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -139,20 +139,20 @@ ref처럼 바인드됨 — **사용자 확정**("A. 맞음. 리프노드에선 Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래 참고. -### 여러 Slot이 섞일 때 순서 보장 — 열린 질문 (2026-08-04 신규) +### 여러 Slot이 섞일 때 순서 보장 — 해소됨 (2026-08-09 여섯 번째 세션) `Frame { Slot1, 일반자식, Slot2 }`처럼 Slot과 Slot 사이에 다른 요소가 끼거나 Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가 -항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지는 아직 설계 안 됨 — -**사용자 확정**("순서도 따름, 만약 웹이라면 순서에 맞춰서 마운트 되어야 하고, -아래쪽 slot은 위쪽 slot의 요소보다 무조건 아래 있어야함... 이게 가능하냐는 -생각해봐야할 이야기임"). Roblox 자체는 z-순서를 주로 `LayoutOrder`/`ZIndex`로 -푸는 편이라 당장 급하게 막히는 지점은 아니지만, `architecture.md`가 명시한 -"quad-base는 다른 렌더 백엔드(GTK, 웹 DOM 등)에서도 재사용 가능해야 한다"는 -전제와 직결되는 문제 — DOM류 백엔드는 형제 순서 자체가 렌더 순서라 이 보장이 -없으면 못 씀. **후순위 열린 질문으로 유지**, Slot 코어 로직 구현 시점에 -다시 볼 것 — M0/M1 단계를 막지는 않음(Roblox 단일 백엔드로는 당장 문제 -없음). +항상 아래쪽 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` 순서) @@ -546,6 +546,26 @@ end - **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론, `isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) — 위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고. -- **여러 Slot이 형제로 섞일 때 순서 보장**은 아직 열려있음(위 "여러 Slot이 - 섞일 때 순서 보장" 절 참고) — Roblox 단일 백엔드로는 급하지 않음, Slot - 코어 로직 구현 시점에 재검토. +- **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때 + 순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은 + `base/bind-system-plan.md`의 "Length/Offset" 절이 최신 소스. + +## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션) + +Slot은 CRUD/`:List` 여부와 무관하게 `.Length: State`를 항상 +노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 `:List` +reconcile이 넣든 동일). 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은 +UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 +순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로 +그 값 — 별도 두 State가 아니라 하나. `:List`의 filter 탈락이 실제 +`Remove`(Visible 토글 아님)로 확정돼 있어서 `Length`는 자동으로 "실제 +마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 `Length`가 그걸 못 잡는 +게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫. + +## 백로그 — `Slot():Single(state, updateFn?)` (2026-08-09 여섯 번째 세션, 미착수) + +`:List`의 key-map(`mounted`/`userdata`/`keyIndex`) 없이 "0개 아니면 1개"만 +다루는 더 가벼운 편의 메소드 제안(예: `state`를 조건부로 마운트하는 +관용구를 더 명시적으로 표현) — `.Length`는 그냥 0/1이고 나머지(offset 소비, +LayoutOrder 바인딩)는 일반 Slot과 완전히 같은 프로토콜. 아직 상세 설계 +안 함, `.claude/question.md`에 백로그로만 반영. diff --git a/.claude/question.md b/.claude/question.md index dba4757..26307ea 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -34,6 +34,9 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - 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. 용어 정리 (사용자 요청, 진행 중) @@ -190,11 +193,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — `reference/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록. -- **여러 Slot이 형제로 섞일 때 순서 보장** — `base/slot-plan.md`의 "여러 - Slot이 섞일 때 순서 보장" 절 참고. Roblox 단일 백엔드로는 급하지 않음 - (LayoutOrder/ZIndex로 대부분 해결), 다른 백엔드(웹 DOM 등) 재사용성과 - 직결되는 문제라 Slot 코어 로직 구현 시점에 재검토. **같은 구현 시점에 - 같이 확인할 것(2026-08-06 추가)**: Slot이 quad 밖(v1 compat 등)에서 +- **[해소됨, 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 참고. diff --git a/CLAUDE.md b/CLAUDE.md index daf9834..e4fca9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2149,3 +2149,113 @@ Source/Store를 새로 안 만들려면 이전 상태를 어딘가 저장해야 `research/documentation-content-map.md` 반영 완료. **다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). + +## 2026-08-09 여섯 번째 세션 — 여러 Slot이 형제로 섞일 때 순서 보장 완전 +해소(Length/Offset), `unbindLifetime` 신설 + +**출발점**: 사용자가 미래의 `quad-web`을 가정하며 `{ Slot, Element, Slot }`처럼 +Slot이 여럿 형제로 섞일 때 최종 순서를 어떻게 보장하는지 물음 — +2026-08-04부터 "Roblox 단일 백엔드로는 급하지 않음"으로 후순위 열려있던 +질문(`slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 절)을 실제로 라이브 +설계해서 완전히 풀어낸 긴 단일 스레드. 시행착오를 거쳐 최종 수렴한 결론만 +정리(중간 대안들 — "구간 예약"/`:With`+`:Compute` 체인 — 은 채택 안 됨, +사용자가 제시한 "정확한 누적합 + 플랫 재계산 루프"가 최종안): + +- **핵심 전환**: "각 원소가 절대 위치를 계산해서 전파"가 아니라 "각 + 구조적 위치가 자기 앞 형제들의 개수 누적합(`offset`)만 알면 됨" — + Roblox `LayoutOrder`가 이미 `Instance.Parent` 물리 순서와 분리된 + 정수 프로퍼티라는 사실이 이 전환을 공짜로 성립시킴. +- **`Dispatch.setLength(inst,i,len:number|State)`/ + `Dispatch.setOffsetSource(inst,i,offset:Source|None)`** — + 둘 다 Handler→Dispatch 등록(push) 방향, array part의 **모든** number + 인덱스에 대해 반드시 호출(생략 UB — Handler 구현체 작성자만의 계약, + 일반 사용자 영향 없음). `recompute`는 매번 `1..N` 전체를 도는 단순 + 루프(N은 저작 시점에 고정된 배열 리터럴 길이라 무시 가능)로, 각 + `offset:Set()` 호출 앞에서만 `Get() ~= sum` 가드를 걸어 실제로 안 + 바뀐 위치의 캐스케이드(다운스트림 `LayoutOrder` 재적용)를 막음 — + 전체 순회 비용과 `Set` 캐스케이드 비용을 분리해서 후자만 최적화. +- **각 원소의 `LayoutOrder`는 `localIndex+offset`의 State를 기존 + store-bind 프로퍼티 바인딩에 그냥 얹는 것** — 이게 이 설계의 가장 + 큰 단순화 지점: "offset 변경 시 이미 마운트된 원소를 다시 써야 한다"는 + 요구가 새 push/observer 메커니즘 없이 **이미 있는** store-bind + 재실행 모델(`state:Observer(fn):Subscribe()`) 재사용만으로 공짜로 + 풀림. +- **`setLength`의 내부 Observer는 leaf-lifetime 경로(`bindLifetime`)를 + 씀, `:Subscribe()` 아님** — 이 Observer는 특정 leaf가 아니라 `inst` + 자신에 종속된 내부 배관이라, `inst` Destroy 시 자동으로 안 죽는 + `:Subscribe()` 경로는 안 맞음. `State` 교체처럼 `inst` 전체가 + 죽기 전에 특정 위치 하나만 조기 재등록해야 하는 경우를 위해 + **`unbindLifetime(inst,value)`을 `bindLifetime`/`canExecute`의 + 세 번째 짝으로 신설** — `Dispatch.setLength`가 gchold 내부 저장 + 구조(배열/키드 테이블)를 몰라도 이전 등록을 블랙박스로 해제할 수 + 있게 캡슐화. quad-roblox 구현 스케치도 gchold를 배열 대신 `value`를 + 키로 쓰는 테이블로 바꿔 `unbindLifetime`을 O(1)로(`gchold[value] = + nil`) — base 결정은 아니고 참고용 스케치. +- **동기 순서 요구사항**: Slot의 `rawAdd`는 `Length:Set(newCount)` + (다운스트림 offset/LayoutOrder 캐스케이드가 여기서 동기적으로 끝남) + 다음에 `element.Parent = target`을 호출 — Source:Set()이 옵저버 + 체인을 동기적으로 끝까지 도는 기존 모델 덕에 별도 배리어 없이 순서만 + 지키면 자동 성립. 안 지키면 Roblox의 실시간 `UIListLayout` reflow가 + 한 프레임 잘못된 순서를 노출할 위험. +- **`Slot.Length: State`가 CRUD/`:List` 여부와 무관하게 항상 + 노출되는 프리미티브 필드로 확정** — 사용자가 직접 "n개 검색됨" UI에도 + 쓸 수 있다고 지적, `setLength`가 내부적으로 읽는 값과 완전히 동일(두 + 용도를 겸함, 별도 State 아님). `:List`의 filter=진짜 Remove 확정 + 덕에 "Visible 토글은 안 잡힘"이 자연히 성립(새 캐비엇 아님). +- **웹 백엔드(quad-web) 일반화 — base 로직 100% 재사용, backend + Handler의 "offset 변경 시 할 일"만 달라짐**: DOM `insertBefore`는 + 물리적 삽입 시 뒤 형제를 자동으로 밀어주므로, offset이 바뀌어도 + 이미 마운트된 노드를 실제로 옮길 필요가 없음 — quad-web Handler는 + offset 변경 관측 시 no-op, 숫자는 그 위치가 **다음** insert/remove + 때 쓸 물리 인덱스로만 부기됨. 처음 검토했던 "구간 예약"(고정 gap)이나 + "앵커 기반 상대 삽입" 안보다 이 방식이 dense global rank라 두 종류 + 백엔드(순서-분리 프로퍼티형/물리-순서형) 모두에 더 직접적으로 맞음. +- **백로그로만 남김**: `Slot():Single(state, updateFn?)` — `:List`의 + key-map 없이 "0 또는 1"만 다루는 가벼운 편의 메소드, 상세 설계 미착수. + +**같은 세션 후속 — `bindLifetime`/`unbindLifetime`이 실제로 뭘 하는지, +`canBound`(이중 바인딩 금지)와의 관계를 여러 차례 시행착오 끝에 정확히 +확정.** `Dispatch.setLength`가 이전 Observer 등록을 정리할 때 뭘 불러야 +하는지를 두고 제가 세 번 틀렸다가 사용자가 매번 정정 — 경위와 최종 +결론을 구분해서 기록: + +1. **1차 시도(틀림)**: `unbindLifetime`이 `canExecute`를 즉시 `false`로 + 만들어준다고 서술 — 틀림. `gchold`(순수 GC 방지용 강참조 테이블)는 + `canExecute`가 보는 값(Observer/Effect의 `.Subscribed`, 또는 `inst`의 + 공유 `gcconn.Connected`) 어디에도 안 들어감, 완전히 무관한 테이블. +2. **2차 시도(틀림)**: 그래서 "`unbindLifetime`은 필요 없고 `:Unsubscribe()` + 만 쓰면 된다"로 후퇴 — 이것도 틀림. 사용자 정정: `:Subscribe()`/ + `:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 + 최상위 디버그 print 등, leaf도 없고 특정 Instance에도 안 묶인 경우)를 + GC로부터 지키기 위한 **전역** 강참조 테이블(`SubscribedObservers[observer] + = true/nil`)일 뿐 — `Dispatch.setLength`의 Observer처럼 처음부터 + `inst` 하나에 종속된 내부 배관에는 원래부터 안 맞는 도구. "`inst` + 연관은 전부 `bindLifetime`/`unbindLifetime`으로"가 맞는 원칙. +3. **최종 확정**: 진짜 독립된 라이프사이클 경로는 **`:Subscribe()`(전역) + 와 `bindLifetime`(inst-scoped) 둘뿐** — "children 배열 leaf 부착"은 + 세 번째 경로가 아니라 **`bindLifetime` 호출 그 자체**(`Dispatch/ + Leaf.luau`가 Observer/Effect leaf를 매치하면 그 자리에서 + `bindLifetime(inst, v)`를 호출), 이걸 제가 처음에 "leaf 부착/ + `:Subscribe()`/`bindLifetime` 셋 다 상호 배타"로 잘못 일반화했다가 + 사용자가 "leaf 부착 자체가 bindLifetime을 호출하는 거라 동일 동작, + 상호배타는 아니다"로 정정. `canBound`의 내부 플래그도 새 필드가 + 아니라 **`canExecute`가 이미 보는 `.Subscribed` 그 자체** — + `bindLifetime`/`unbindLifetime`도(Observer/Effect 값에 한해) 이 + 필드를 세팅/해제해야 `bindLifetime`으로 등록된 Observer가 + `canExecute`에서 정상적으로 "살아있음"으로 인식됨. Effect는 내부적으로 + Observer를 조합하므로 이 확장을 몰라도 자동으로 커버(사용자 확인). +4. **부수 정리**: 이미 확정돼 있던 StoreBind의 자기 재실행 Observer + 예제(`observer:Subscribe()`)도 같은 이유로 틀렸던 것이었음 확인 — + `bindLifetime`/`unbindLifetime`으로 교체. "`:Unsubscribe()`는 자동 + (리프) 케이스에도 동일하게 씀"이라던 기존 서술도 같은 이유로 정정 + (리프/`bindLifetime` 경로의 조기 해제는 `unbindLifetime` 전용, + `:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함). + +전부 `base/bind-system-plan.md`(신규 "Length/Offset" 절, "이중 바인딩 +금지" 절 정정 — 2-way로 재확정, StoreBind 예제 교체)/`base/slot-plan.md` +(열린 질문 해소, `Slot.Length` 절, `:Single` 백로그 절)/`base/ +lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed` +연동 반영)/`ROADMAP.md`(M2/M3/M6)/`.claude/question.md` 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index cc00409..023f66b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,18 +93,35 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를 대체(2026-08-08 세션 신설). - [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(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 네 번째 세션에 반영). + `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`를 봄. `bindLifetime`/`canExecute` 둘 다 네임스페이스 + 저장)의 `.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` — 확정" 절 참고 + lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime` + — 확정" 절 참고. **Observer/Effect 값에는 `bindLifetime`/ + `unbindLifetime`도 M3의 `canBound` 게이트를 확인/세팅** — children + 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라서(M3 체크박스 + 참고, 구현 순서상 M2가 M3의 `canBound`를 참조하게 됨에 유의) +- [ ] `Dispatch.setLength(inst,i,len:number|State)`/ + `Dispatch.setOffsetSource(inst,i,offset:Source|None)` — + array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브 + 바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략 + UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime + 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 + (2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset" + 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) - [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서 걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러 교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약" @@ -143,10 +160,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) -- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로 leaf - 부착과 `:Subscribe()`가 동시에 걸리면 즉시 `error`(`base/bind-system-plan.md` - "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 이름은 - 2026-08-09 세션에 `canBound`로 확정) +- [ ] 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 반응형 업데이트 @@ -168,8 +190,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M6 — Slot -- [ ] "여러 Slot이 형제로 섞일 때 순서 보장" 열린 질문 확인(`slot-plan.md`) — - Roblox 단일 백엔드로는 급하지 않으면 스킵하고 진행 가능 +- [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째 + 세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/ + bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State`도 + 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" + UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. - [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론 확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지 전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 From 97c074ed935ebec1ae334b7bd1be7711e4350845 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 21:03:23 +0900 Subject: [PATCH 55/62] =?UTF-8?q?fix(base):=20Length/Offset=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B5=9C=EC=A2=85=20=EC=A0=90=EA=B2=80=20=E2=80=94?= =?UTF-8?q?=20recompute=EC=9D=98=20None=20=EC=B2=98=EB=A6=AC=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8,=20setLength=20=ED=98=B8=EC=B6=9C=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EC=86=8C=EC=9E=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recompute 스케치가 offset==None을 truthy로 오통과시켜 None:Get()을 부르는 실제 버그를 발견해 수정. sourceList가 nil 대신 None을 쓰는 근거, Slot 자신이 아니라 그 위치를 매치한 Handler가 setLength를 호출한다는 책임 소재도 명시. --- .claude/base/bind-system-plan.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 9b847db..0c463f2 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -435,7 +435,14 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수 `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 - store-bind 핸들러가 값이 바뀔 때마다 다시 호출. + store-bind 핸들러가 값이 바뀔 때마다 다시 호출. **호출 책임은 `Slot` + 자신의 `:List`/CRUD가 아니라 그 위치를 처음 매치한 Handler(`Dispatch/ + Slot.luau`)** — `Slot`은 `inst`/`i`를 모르는 독립 값(어디 마운트될지 + 자기가 결정 안 함)이라, `process(inst, i, slotValue)`가 매치되는 + 시점에 그 Handler가 `Dispatch.setLength(inst, i, slotValue.Length)`를 + 1회 호출(길이 자체가 바뀌는 매 순간은 이미 `slotValue.Length`가 + `State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state` + 교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출. - **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 하고, `recompute`가 그 자리에 값을 `:Set()`함. Handler는 이 **같은** @@ -452,7 +459,20 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. **저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 -`inst`의 array part 크기 `N`만큼) — `Relate(parentInst)`에 lazy 생성. +`inst`의 array part 크기 `N` — `bk.N`으로 같이 저장, `Dispatch.drive`가 +최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 +lazy 생성. + +**`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트 +원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) +`nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 +구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) +— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, `Ref` +콜백/대기자 배열·PreRef pre-pass에 이미 적용된 것과 같은 원칙(위 "왜 +`nil`이 아니라 `None`인가" 절 참고). 다만 `recompute`가 `1..N` 고정 +범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 자체는 안 +생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 여부 +구별과 접근 비용" 쪽. **recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: @@ -463,7 +483,9 @@ local function recompute(inst, bk) local v = bk.lengthList[i] sum += (isState(v) and v:Get() or v) local offset = bk.sourceList[i] - if offset and offset:Get() ~= sum then -- 실제로 다를 때만 Set + -- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라 + -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함 + if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set offset:Set(sum) end end From 5836c2d12af6333248d7d16780c5f2a9f757212a Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 21:42:24 +0900 Subject: [PATCH 56/62] =?UTF-8?q?decide(base):=20Slot:List=EC=9D=98=20data?= =?UTF-8?q?:Observer=20=EA=B5=AC=EB=8F=85=EC=9D=84=20=EB=A7=88=EC=9A=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=9C=EC=A0=90=20lazy=20bindLifetime=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch.setLength는 이미 마운트 시점에 bindLifetime을 걸었지만, Slot:List의 data:Observer(fn) 구독은 :List() 호출 시점(inst를 모르는 시점)에 즉시 생성되어 Destroy 후에도 재실행/관측을 멈출 방법이 없었음. :List()는 이제 설정만 저장하고, 실제 구독+최초 reconcile은 Slot 마운트 시점에 activateList로 수행 — 마운트 이후 :List() 호출 시 self._mounted로 즉시 활성화. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/base/slot-plan.md | 100 +++++++++++++++++++++++++++++++++++--- CLAUDE.md | 47 ++++++++++++++++++ ROADMAP.md | 7 ++- 3 files changed, 145 insertions(+), 9 deletions(-) diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 134f56c..d274eea 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -26,6 +26,12 @@ unmount(`Remove`) 둘이 아니라 **reposition(`Move`/`Swap`)까지 셋** — 계약만 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/ @@ -103,7 +109,10 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 소진, 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 인스턴스에 안 묶임 ("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). @@ -422,12 +431,31 @@ 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 - keyFn = keyFn or function(_, index) return index end + 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) @@ -461,13 +489,16 @@ function Slot:List(data, updateFn, keyFn) keyIndex = newKeyIndex end + local data = self._listData if isState(data) then - data:Observer(function() reconcile(data:Get()) end) - -- Observer는 등록 즉시 1회 실행 확정 -> 최초 population도 공짜 + local observer = data:Observer(function() reconcile(data:Get()) end) + -- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute/Subscribed + -- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) — + -- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속 + bindLifetime(inst, observer) else reconcile(data) end - return self end ``` @@ -491,9 +522,10 @@ end 못 치워지고 샘 — 직전 사이클에 실제로 존재했던 **전체** key 집합 (`keyIndex`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를 놓치지 않음. -- **`mounted`/`userdata`/`keyIndex`**: 이 Slot 인스턴스의 평범한 로컬 - 필드(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `self`가 - 살아있는 동안만 존재하면 되고 Slot이 죽으면 클로저도 같이 GC됨. +- **`mounted`/`userdata`/`keyIndex`**: `activateList`(마운트 시점 1회 + 실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) + 불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도 + 같이 GC됨(아래 "구독 시점" 절). - **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawRemove`/`rawMove`뿐** — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그 @@ -504,6 +536,58 @@ end 저비용 경로. 최소-이동 알고리즘(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`을 diff --git a/CLAUDE.md b/CLAUDE.md index e4fca9f..564957b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2259,3 +2259,50 @@ lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed` **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-09 일곱 번째 세션 — `Slot:List`의 `data:Observer(fn)` 구독도 +마운트 시점 lazy `bindLifetime`으로 확정 (Destroy 후 재실행 gap 해소) + +사용자가 "Slot이 마운트된 대상이 Destroy로 죽으면 `updateFn` 재실행이 +`canExecute`로 막히고 있는 게 맞냐"고 질문하며 시작 — 확인 결과 **두 메커니즘이 +다른 상태였음**: `Dispatch.setLength`(Length/Offset, 여섯 번째 세션 확정)는 +이미 정확히 그렇게 돼 있었지만(Slot 마운트 시점에 `bindLifetime(inst,observer)`), +`Slot:List`의 `data:Observer(fn)` 구독은 `:List()` 호출 그 자리에서 즉시 +만들어져(`inst`를 모르는 시점) `bindLifetime`이 전혀 안 걸려있던 실제 gap — +사용자가 정확히 캐치함. 사용자가 이어서 "실제로 Instance에 바인드되려 시도될 +때(=마운트 시점)로 구독 자체를 lazy하게 미루면 되지 않냐"고 제안, 검증 후 +확정. `base/slot-plan.md`(`:List`의 "구현"/"구독 시점" 절 재작성 + +"base/roblox 패키지 경계" 절 보강)/`ROADMAP.md`(M6)에 반영 완료: + +- **`Dispatch.setLength`가 이미 쓰던 패턴을 그대로 재사용, 새 메커니즘 + 없음.** `:List(data,updateFn,keyFn)`는 이제 설정만 저장하고 반환 — + 실제 `data:Observer(fn)` 구독과 최초 `reconcile`은 Slot 컨테이너 자신이 + 마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)`, `self._mounted`를 + 세팅하는 바로 그 자리)에 `activateList(self,inst)`가 수행. +- **`:List()`가 마운트 이후에 불리는 경우 — `self._mounted`면 즉시 활성화로 + 확정(사용자 확인, 세 가지 대안 중 1번).** 마운트는 1회성 이벤트라 순서가 + 뒤바뀌면 그 이벤트를 못 기다리므로, `:List()`가 `self._mounted`를 직접 + 확인해서 이미 참이면 그 자리에서 즉시 `activateList` — 호출 순서 제약을 + 새로 추가하지 않음. +- **canExecute와 "등록 즉시 1회 실행"의 관계를 사용자가 직접 짚어 확정**: + `data:Observer(fn)` 등록 시점(=`bindLifetime` 호출 *이전*)의 최초 1회 + 실행은 `canExecute`/`Subscribed` 게이팅과 무관하게 무조건 일어남 — 이 + 시점엔 아직 `Subscribed`가 안 세팅돼 `canExecute`를 물으면 거짓이겠지만, + 애초에 최초 실행은 게이팅 대상이 아니라서 상관없음(`Dispatch.setLength`가 + 이미 "등록 즉시 1회와 겹쳐도 무해"로 같은 구조를 갖고 있었음). `bindLifetime`은 + 등록 직후에 걸려 **이후** 재실행만 게이팅. +- **Destroy 이후 "재실행 막기"+"관측 자체를 관두기"가 새 코드 없이 한 번에 + 해결됨** — `inst` Destroy 시 `gcconn`이 죽어 `canExecute`가 거짓이 되고 + 향후 재실행이 no-op되는 동시에, `gchold`가 `Relate(inst)`(weak-keyed) + 아래 있어서 `inst`가 죽으면 그 안에 강참조로 잡혀있던 Observer/클로저 + (`mounted`/`userdata`/`keyIndex` 포함)가 전부 GC 대상이 됨 — 명시적 + 구독 해제 코드가 안 필요함, `lifecycle-pattern.md`의 "정리는 기본적으로 + GC에 위임" 원칙 그대로. +- **부수 관찰(메모만, 설계 아님)**: 사용자가 "`Relate`로 마운트된 대상을 + weak하게 구할 수도 있겠다"고 언급 — `bindLifetime`이 `Relate(inst)` 기반이라 + 나중에 "이 `inst`에 지금 뭐가 붙어있는가" 역조회가 같은 저장소로 가능해 + 보임, quad-debug 그래프 UX와 맞닿을 수 있음. 지금 설계 안 함, 필요성 + 확인되면 그때. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index 023f66b..b850aeb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -229,7 +229,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 기각(Slot 부모 자체가 Destroy되는 경로에선 이 훅이 전혀 안 불려서 절반만 동작, `retract`가 Destroy 시 안 불리는 것과 같은 이유). (2026-08-09 세 번째 세션 확정, - `base/slot-plan.md` "`Slot:List(...)`" 절) 구현 + `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, 구현 선택) From f198fd9c6bbd08b7ef2adcf3c207e66ba905a852 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 23:37:46 +0900 Subject: [PATCH 57/62] =?UTF-8?q?fix(base):=20=EC=A4=91=EA=B0=84=EA=B2=80?= =?UTF-8?q?=ED=86=A0(=EC=A7=88=EB=AC=B8=20=EB=AA=A8=EB=93=9C)=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=9C=EA=B2=AC=EB=90=9C=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EA=B2=B0=ED=95=A8=20=EB=8B=A4=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/base/ 전체를 배치별로 리스팅해 사용자 확인을 받는 중간검토 세션 — Ref 콜백/대기자 배열의 None 소진이 무한 성장 버그였던 것을 nil로 되돌리고, isRef/isPreRef를 isState/isSource와 같은 상위-하위 합성으로 재정정, Slot CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 전환(ExtractAll/Get/IndexOf 신설), 컴포넌트 리프 바인딩에서 Source 직접 사용이 정상 경로라는 정정, Dispatch 직접 호출 UB 명시, Tag retract 전제 명시, Attribute 타입 파라미터화 확정, EffectHandle 내부 Observer cascade/Subscribe GC 예외 경고 등을 반영. CLAUDE.md에 세션 요약, stale해진 research/documentation-content-map.md 일부 항목도 동기화. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/README.md | 3 +- .claude/base/attribute-plan.md | 36 +-- .claude/base/bind-system-plan.md | 211 +++++++++++++----- .claude/base/component-composition-plan.md | 43 +++- .claude/base/effect-plan.md | 36 +++ .claude/base/modifier-plan.md | 11 + .claude/base/slot-plan.md | 84 ++++--- .claude/base/tag-plan.md | 16 +- .claude/base/ui-shorthand-plan.md | 11 + .claude/question.md | 12 +- .claude/reference/comparison-charm.md | 135 +++++++++++ .claude/research/documentation-content-map.md | 28 ++- CLAUDE.md | 120 ++++++++++ ROADMAP.md | 54 +++-- 14 files changed, 645 insertions(+), 155 deletions(-) create mode 100644 .claude/reference/comparison-charm.md diff --git a/.claude/README.md b/.claude/README.md index 4d81a04..d2e26be 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -30,7 +30,7 @@ | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | | `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드) | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`, `keyFn` 생략 시 index 기본값, `updateFn(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn`이 `userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`, `keyFn` 생략 시 index 기본값, `updateFn(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn`이 `userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | @@ -47,6 +47,7 @@ |---|---| | `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/` — 아직 착수 전, 상의 필요 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index d147134..f00c1c7 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -1,7 +1,8 @@ # Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기 -**상태**: base(메커니즘/`None`/`retract` 동작은 확정) — 타입 파라미터화 -이름만 미확정. `[Attribute "Name"]` DI 키의 존재 자체는 `architecture.md` +**상태**: 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 @@ -23,23 +24,28 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att "Value 오브젝트 기각, Attribute로 확정" 결정과 같은 방향 — Instance 타입 지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐). -**후보 두 가지 (미확정)**: +**확정(2026-08-09 열한 번째 세션) — 둘 다 채택**: - `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — - 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. + 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 기본/범용 경로. - `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ - `InstanceAttribute` 등). + `InstanceAttribute` 등). 실사용 빈도가 높은 몇 개만 지름길로. -**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 -패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 -구조적으로 똑같은 문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 -필드로 나눌지" 고민이 있었고, 결론은 **둘 다**(`new(className)` -제네릭 생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 -같은 모양을 재사용하면 자연스러울 가능성 — `Attribute("name")` 제네릭을 -기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ -`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/ -`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 사용자 확인 전 -소견일 뿐 — `.claude/question.md`에 반영, 사용자 판단 필요. +**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 +생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론 +재사용 — `new(className)` 제네릭 생성자 + 자주 쓰는 ~25개는 +정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히 +동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히 +호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느 +쪽을 쓰든 런타임 동작에 차이 없음. + +**[실측 필요, M0/M10]** `[Attribute<> "name"] = value`처럼 DI +키 제네릭 파라미터로 `=` 뒤 `value`의 타입까지 실제로 좁혀지는지는 +미검증 — Luau 솔버가 이 조합을 못 풀면 `value`가 `any`로 남을 수 있음. +단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적 +타입체크 실패일 뿐, `SetAttribute` 호출 자체는 항상 정상 작동) — 안 +되면 `BooleanAttribute` 같은 정적 타입 패밀리 쪽이 사실상 유일하게 +믿을 수 있는 정적 체크 경로가 됨. ## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 0c463f2..a18869f 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -365,6 +365,15 @@ function Dispatch.retractUnder(inst, k, keep, v) end ``` +- **`handler.process(inst,k,v)`를 `Dispatch.process`를 거치지 않고 직접 + 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.** + 이유: `chains` 배열에 push하는 bookkeeping이 `Dispatch.process` 내부에만 + 있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 활성화됐는데도 + 체인에 안 올라가 — 나중에 다른 값으로 바뀌어도 `retractUnder`가 이 + 핸들러의 존재를 몰라 `retract`가 영영 안 불리거나(리소스 누수), 반대로 + 체인 순서 자체가 실제 활성 상태와 어긋나는 정합성 붕괴로 이어짐. 재귀/ + 래핑 핸들러가 위임할 때도 항상 `Dispatch.process(inst,k,newV)`를 + 불러야지 매치된 핸들러의 `.process`를 스스로 찾아 직접 호출하면 안 됨. - **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst, k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)`를 부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신) @@ -450,8 +459,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) (`localIndex:With(offset):Compute(function(i,o) return i+o end)`을 `LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind 재실행 메커니즘이 알아서 다시 씀 — 새 push/observer 시스템 불필요). - **실제 마운트를 하지 않는 위치(Ref/PreRef 등)는 `None`을 등록** — 순서 - 계산에 참여할 게 없다는 명시적 선언. + **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에 + 참여할 게 없다는 명시적 선언. 대상은 Ref/PreRef뿐 아니라 **그 배열 + 위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로 + 캐우칭된 미전달 Ref, PreRef pre-pass가 소진시킨 슬롯 등) — `setLength`도 + 같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의 + "`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면 + 길이 합계와 실제 순서 계산이 어긋남). **둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB (2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — @@ -467,12 +481,15 @@ lazy 생성. 원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) `nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) -— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, `Ref` -콜백/대기자 배열·PreRef pre-pass에 이미 적용된 것과 같은 원칙(위 "왜 -`nil`이 아니라 `None`인가" 절 참고). 다만 `recompute`가 `1..N` 고정 -범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 자체는 안 -생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 여부 -구별과 접근 비용" 쪽. +— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, PreRef +pre-pass 소진 슬롯에 이미 적용된 것과 같은 원칙(위 "PreRef" 절의 +"왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 최종적으로 `nil`로 +되돌아간 건 Ref 콜백/대기자 배열 한정**이고 `sourceList`/PreRef +pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야 +하는 배열은 여전히 `None`이 맞음, 헷갈리지 말 것). 다만 `recompute`가 +`1..N` 고정 범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 +자체는 안 생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 +여부 구별과 접근 비용" 쪽. **recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: @@ -722,8 +739,19 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 - 가능(발화 후 해당 인덱스만 **`None`으로 소진** — 아래 구현 디테일의 - 2026-08-07 열 번째 세션 정정 참고, 단순 `nil` 처리는 아님). + 가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고, + **[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**, + 바로 아래 캐비엇 참고). + - **`.Value`는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고 + `__index` 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강)** — + Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, `.Value`를 + `self.Value = v`로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에 + 같이 걸림. `T`가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스, + 위 "object-ref/function-ref로 나누지 않음" 참고), `for i, v in self do` + 같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이 + 경우 `.Value`가 콜백/대기자 처리 루프에 잘못 걸려 `type(v)`로 + 오분류될 위험이 생김. `__index`로 실제 저장 위치를 배열과 분리해두면 + 이 충돌 자체가 안 생김. - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -737,38 +765,45 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`가 `nil`이면 yield, 있으면 yield 안 함. - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume - payload 정정, 열 번째 세션에서 소진 방식 정정)**: 값이 새로 `:Set()`될 - 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 + payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로 + `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`의 리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value`를 넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서 - 이번 세션에 정정) 후 **`[i] = None`**(**`nil`이 아님** — 아래 - "왜 `nil`이 아니라 `None`인가" 참고), 아니면 일반 콜백 함수로 보고 - 그냥 `v(value)`(콜백은 여전히 원래 값을 직접 받음, 소진 안 함, 계속 - 유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 - 값 타입 하나로 분기 가능(`type(v) == "thread"` → 대기자, - `type(v) == "function"` → 콜백, 그 외/`None` → 빈 슬롯이라 스킵). - 새 콜백/대기자 등록은 `table.insert`로 끝. - - **왜 `nil`이 아니라 `None`인가(2026-08-07 열 번째 세션, 사용자가 실제 - Luau REPL로 반례 제시 후 정정) — 이전 서술("성긴 배열이어도 일반화 - `for`가 계속 잘 순회하므로 압축 불필요")은 절반만 맞았음.** 대기자/콜백 - 자체는 순서가 안 중요해서(어느 게 먼저 fire되든 상관없이 전부 fire되기만 - 하면 됨) "잘 순회함"까지는 맞았지만, 두 가지를 놓쳤음: (1) 키가 촘촘한 - 저범위 정수(1,2,3,...)에서 벗어나 듬성듬성해지면(`nil`로 지운 슬롯도 - 포함) Luau/Lua 테이블이 그 키들을 해시 파트로 취급해 순회 순서가 해시 - 버킷 순서가 되어버림(사용자가 `{[1]=1,[2222]=2222,[211]=211,...}`류 - REPL 실측으로 확인 — 대기자/콜백 리스트 자체는 이 순서 소실이 문제 - 안 되지만, 순서가 실제로 중요한 다른 배열(`PreRef` pre-pass 등)엔 - 치명적). (2) `table.insert`가 내부적으로 쓰는 `#t`(length 연산자)는 - Lua 명세상 구멍이 있는 테이블에서 **정의되지 않은 동작**이라, 다음 - 콜백/대기자 등록이 엉뚱한 인덱스에 들어가 기존 항목을 덮어쓸 위험이 - 있음 — 이건 대기자/콜백 리스트에도 실제로 해당하는 진짜 버그. - `None`은 `nil`이 아닌 **실재하는 값**이라 그 슬롯이 "차 있다"는 사실 - 자체는 안 바뀌므로 두 문제 다 피함 — 소진된 슬롯도 여전히 non-nil - 값을 갖고 있어 테이블이 "구멍 없는 시퀀스"라는 불변식이 깨지지 않음. + 정정) 후 **`[i] = nil`**로 소진(아래 "왜 `None`이 아니라 `nil`인가" + 참고), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 여전히 + 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을 + 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능 + (`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백, + `nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`가 + 아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는 + 등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그 + 자리를 되찾아 쓸 수 있음. + - **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정) + — 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안 + 맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는 + 정수 키가 해시 파트로 튀어 순회 순서가 깨짐, `table.insert`의 `#t`가 + 구멍 있는 테이블에서 미정의 동작)는 **순서가 실제로 중요한 배열** + (`PreRef` pre-pass, Length/Offset의 `sourceList` — `1..N` 고정 + 범위로 도는 `for` 루프라 구멍이 있으면 안 됨)에는 맞는 처방이지만, + Ref의 콜백/대기자 배열은 애초에 **순서가 중요하지 않다**(어느 게 + 먼저 fire되든 전부 fire되기만 하면 됨) — 일반화 `for i,v in tbl do`는 + 구멍이 있어도 순서가 뒤섞여도 **모든 엔트리를 빠짐없이 방문**하므로 + "순서 보장이 깨진다"는 문제 자체가 이 배열엔 없음. 오히려 `None`을 + 쓰면 소진된 슬롯이 영원히 non-nil로 채워진 채 남아 **매 `:Wait()` + 호출마다 배열이 끝없이 길어지는** 새 문제가 생김(등록이 항상 끝에만 + 추가되고 예전 슬롯을 재사용 못 함) — `nil`로 지우면 다음 등록이 그 + 빈 슬롯을 재사용할 수 있어 배열 크기가 동시 대기자 수만큼만 유지됨. + `table.insert`의 `#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈 + 슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던 + 이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이 + 필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열 + (PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속 + `None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할 + 이유가 없었음. - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 @@ -894,11 +929,14 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번 순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 `isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i] - = None`**으로 소진(`nil`이 아님 — 위 "왜 `nil`이 아니라 `None`인가" - 절과 같은 이유, 2026-08-07 열 번째 세션 정정: `nil`로 지우면 그 - 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 - 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 - 보장이라 치명적). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 + = None`**으로 소진(`nil`이 아님, 2026-08-07 열 번째 세션 정정: `nil`로 + 지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 + 순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 + 바로 그 보장이라 치명적. **[주의, 2026-08-09 열한 번째 세션] Ref + 자신의 콜백/대기자 배열은 이 이유가 적용되지 않아 `nil` 소진으로 + 되돌아갔음(위 "Ref 일반화" 절 참고) — 여기 PreRef pre-pass는 순서 + 보장이 실제로 필요한 별개 케이스라 `None` 소진이 계속 맞음, 두 + 사례를 혼동하지 말 것**). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 **같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상 `Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이 직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용 @@ -918,6 +956,17 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가 엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은 이 오탐을 막기 위해 반드시 필요. + - **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) — + `NoneHandler.isHandlable(inst,k,v) = (v == None)`은 `k` 타입을 전혀 + 안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만, + 실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의 + `None`은 **애초에 `Dispatch.process` 자체를 절대 안 탄다**(두 패스 + 루프가 `Dispatch.process` 호출 전에 자기 스스로 + `if v == None then continue end`로 걸러냄). `NoneHandler`는 + `Dispatch.process`를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의 + `None`이 거기 아예 도달하지 않는 이상 `k=number` 조합으로 + `NoneHandler`가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가 + 정확한 설명. - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props 순회 순서" 절은 `{a=1, 2, b=3}`류 **구멍 없는** 테이블에서 배열 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 @@ -1375,6 +1424,18 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 포인트). +- **⚠️ 이건 quad 전역의 "정리는 기본적으로 GC에 위임" 원칙의 의도적 + 예외 — 문서에 명시적으로 경고할 것(2026-08-09 열한 번째 세션).** + `:Subscribe()`로 등록한 뒤 로컬 변수 참조를 전부 놓아도(스코프 이탈, + 변수 재할당 등) **GC되지 않고 영원히 계속 실행됨** — 강참조 + 레지스트리가 그 자체로 생존을 보장하기 때문. `bindLifetime`(leaf + 부착 포함) 경로는 `inst`가 죽으면 자동으로 정리되는 GC-native 그대로지만, + `:Subscribe()` 경로는 오직 명시적 `:Unsubscribe()` 호출로만 끊김 — 이 + 차이를 모르고 "quad는 다 GC-native니까 참조만 버리면 되겠지"라고 + 가정하면 조용한 누수(메모리뿐 아니라 계속 재실행되는 콜백까지)로 + 이어짐. 용도도 "완전히 top-level(어떤 Instance 생명주기에도 안 묶인) + 사이드 이펙트"로 좁게 문서화할 것 — 특정 `inst`에 묶인 경우는 + `:Subscribe()`가 아니라 leaf 부착(`bindLifetime`)이 정상 경로. - **`:Subscribe()`/`:Unsubscribe()` 둘 다 `self`를 리턴(대칭)** — `local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에 끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn) @@ -1724,6 +1785,20 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 됨, 순수 편의용 초기값 템플릿). +- **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의 + `default` 인자가 "선택"이라는 서술은 정확히는 `T`가 `nil`을 포함할 때만 + 성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()` + (무인자)는 `Source(nil)`과 동치라고 이미 명시돼 있으나, 이게 타입 + 레벨에서 뭘 뜻하는지(`T`가 nilable이 아니면 타입과 실제 저장값이 + 어긋난다는 것)는 지금까지 명시적으로 안 적혀 있었음. `Ref`도 마찬가지 + 캐비엇이 있고 오히려 더 눈에 띄게 드러남 — `:Callback(fn)`은 등록 + 즉시 그 시점 값으로 무조건 1회 호출되므로(미설정 상태여도 그 상태 + 그대로 호출, 아래 `Ref` "바인드 방법" 절 참고), `default`를 생략한 + `Ref()`에 콜백을 걸면 그 콜백이 즉시 `nil`로 한 번 불림 — `T`가 + non-nilable이면 이 시점에 이미 타입 위반. 따라서 `default`를 생략해도 + 되는 건 오직 `T`가 nilable(`T?`)로 선언된 경우뿐이라는 걸 문서 차원에서 + 명시할 것(non-nilable `T`에 `default` 없이 생성하는 건 사용자 실수, + 타입으로 막을 수 있으면 막고 안 되면 UB로 문서 경고). **[정정, 2026-08-07]** 아래 두 문장은 이후 라운드에서 정정된 옛 서술 — 실제 메커니즘·mutate 취급은 `base/store-semantics.md` "Source가 State를 만족함" 절이 최종 소스: (a) "`__newindex`/`__index` 프록시로 감싸면 @@ -1961,16 +2036,27 @@ Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교 **`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — 단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 -관계(subtype)가 있어 집합 멤버십이 필요한 경우(`isState`)로 갈림: +관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우 +(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 +"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을 +"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로 +재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지 +(포함 관계의 방향)가 코드 모양 자체에 드러나게 함: ``` -local function isState(x) - local t = Brand.get(x) - return t == StateTag or t == SourceTag -- Source가 State를 구조적으로 만족 -end local function isSource(x) return Brand.get(x) == SourceTag end +local function isState(x) + return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족 +end + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isRef(x) + return isPreRef(x) or Brand.get(x) == RefTag -- PreRef가 Ref 런타임을 재사용 = Ref의 한 종류 +end ``` **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 @@ -1985,20 +2071,25 @@ end 불필요" 서술도 같이 정정 대상. **갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 -추가(2026-08-07 열 번째 세션).** 위 코드 예시가 원래 `RefTag`/ -`PreRefTag`/`ModifierTag`를 안 만들어뒀는데, 이 문서 곳곳(PreRef 절의 -`isPreRef(v)`, `component-composition-plan.md`의 `isModifier(v)` 등)이 -이미 이 predicate들이 존재한다고 전제하고 써왔음 — 실제로 만들어야 하는 -게 맞아서 태그 목록에 추가. **`isRef`/`isPreRef`는 `isObserver`와 같은 -단순 항등**(`isRef(x) = Brand.get(x) == RefTag`, `isPreRef(x) = -Brand.get(x) == PreRefTag`) — `isState`처럼 집합 멤버십이 아님, 즉 -**`isRef(preRefInstance)`는 `false`.** 이게 중요한 이유: `PreRef`가 -"Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 해서 `isRef`가 -`PreRef`도 통과시키면, 일반 `(v=Ref)` 매치 핸들러가 `PreRef` 인스턴스도 -집어삼켜버려 위 "PreRef" 절이 요구하는 "일반 Ref 경로를 절대 타면 안 -됨"이 깨짐 — `Ref`/`PreRef`는 State/Source 같은 상하위 관계가 아니라 -서로 배타적인 형제 브랜드. `isModifier`도 같은 단순 항등 -(`Brand.get(x) == ModifierTag`). +추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가 +재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를 +`isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로 +취급(`isRef(preRefInstance)`가 `false`)했으나, 이건 `isState`/`isSource` +쌍과 비일관적이었음 — `Source`가 State를 구조적으로 만족하듯, +**`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함 +방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘. + +- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) == + PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로 + 얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)`는 `true`.** +- **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는 + 이제 `isHandlable`을 `isRef(v) and not isPreRef(v)`로 명시적으로 + 좁혀야 함** — 예전처럼 `isRef` 자체가 배타적이라 저절로 걸러지는 게 + 아니라, "Ref이긴 한데 그 중 PreRef는 아니다"를 호출부가 명시적으로 + 말해야 하는 모양으로 바뀜(PreRef pre-pass가 이미 소진시켜 정상 경로에선 + 거의 안 걸리지만, 위 "PreRef" 절의 동적 경로 가드 Handler와 이 조합이 + 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장). `isModifier`도 같은 + 단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념 없음). **같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — `Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 8cbc275..3457d0e 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -90,13 +90,29 @@ Svelte `Writable extends Readable`와 같은 모양), `store.key`는 Store 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함"). -### 5. 리프(Roblox 프로퍼티) 바인딩엔 원칙적으로 State만 +### 5. 리프(Roblox 프로퍼티) 바인딩 — Source 직접 바인딩도 정상 경로, +"좁은 예외"라는 표현이 오해를 유발해 정정함(2026-08-09 열한 번째 세션) -계산된 최종값만 실제로 인스턴스에 반영되어야 하므로, 리프 바인딩은 State가 -일반 경로. Source는 리프 바인딩용 프리미티브가 아니라, 아주 단순한 구조에서 -콜백 보일러플레이트를 줄이기 위한 좁은 용도의 예외 — **사용자 확정** -("리프 바인딩엔 state만 쓰이지 않을까... source는 그냥 아주 단순한 -구조에서 콜백을 넣고 하는 복잡함을 줄이기 위함일 뿐임"). +**[정정] 이전 서술("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 경계 통과 문제 관련) @@ -206,8 +222,12 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴 버그 실측 후 확정).** caller가 `props.Modifier`/`props.Ref`를 안 넘기면 `nil`인데, `{nil, props.Ref, child}`처럼 Lua 배열 리터럴에 `nil`이 그대로 들어가면 그 순간 테이블의 배열 파트 전체가 순회 순서 보장을 잃을 위험이 -있음(`base/bind-system-plan.md` "왜 `nil`이 아니라 `None`인가" 절 — Luau -REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 테이블 전체에 영향). +있음(`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 } @@ -254,7 +274,12 @@ Fusion/Vide(named prop 전달, `[Children]`류 예약 키)가 서로 다른 이 파라미터를 선언하지 않으면 그만 — 타입 시그니처 자체가 "나는 단일 대상에게 적용할 modifier/Ref가 없다"를 표현. 별도 조율 메커니즘 불필요 — **사용자 확정**("불가능하진 않고 기술적으로도 충분히 되는 일... 엄청 집중해야할 - 일은 아니지 않을까"). + 일은 아니지 않을까"). **[재확인, 2026-08-09 열한 번째 세션]** 새 배선 + 없이 그대로 작동함을 재확인 — `Frame { Comp{} }`에서 `Comp`가 `Slot`을 + 반환하면, 그 반환값이 그냥 children 배열의 한 항목(값)이 되고 + `Dispatch/Slot.luau`의 기존 Slot 매치 핸들러가 평소처럼 처리(값이 + 컴포넌트 호출로 왔든 리터럴로 직접 놓였든 디스패치 입장에선 구분이 + 없음) — 이 경로 전용 특수 취급이 전혀 필요 없다는 뜻. 이 정리로 원래의 "모호해지는 케이스"는 사라짐: 컴포넌트가 단일 root를 갖는 한 named parameter로 명확히 전달되고, 단일 root가 없는 컴포넌트(Slot 반환)는 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 8d74af6..28e3a98 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -58,6 +58,31 @@ 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 설계와 @@ -85,6 +110,17 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 자신(또는 `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의 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index f78320f..ed8dfc7 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -206,6 +206,17 @@ UB로 남겨둠")은 폐기. 재검토 근거(사용자): Modifier는 애초에 - **State/Source는 여전히 허용** — 이 체크는 핸들러 계층 값만 잡음, 4-1번 절의 "필드가 State일 수도 있음"과 안 부딪힘(`isState`가 참인 값은 이 체크를 그냥 통과함). +- **한계, 명시적 UB로 남김(2026-08-09 열한 번째 세션) — `State`류 + "State/Source가 담고 있는 값"이 핸들러 계층 값인 경우는 이 체크로 + 못 잡음.** `isRef(v)` 등은 setter가 확정하는 바로 그 값(State 자체 + 또는 plain 값)만 보므로, 값이 State/Source면 그 껍데기가 `isState`를 + 통과해 검사를 그냥 지나가고, 그 State가 나중에 `:Get()`됐을 때 실제로 + 내놓는 내용물(예: 그 State가 Ref/PreRef/Observer/Effect/Slot을 값으로 + 들고 있는 경우)까지는 검사하지 않음 — 검사 시점엔 아직 실체화 안 된 + 값이라 정적으로 알 수 없고, 값이 바뀔 때마다 매번 `:Get()`해서 + 검사하는 건 관측 시점을 앞당기는 부작용까지 생기는 오버엔지니어링. + **이 안쪽 케이스는 방어 로직 없는 순수 UB로 문서화만 하고 넘어감** — + 의도치 않게 자주 발생할 이유가 없는 조합이라 실사용 위험은 낮음. - **7번 절(`State` UB)과의 비대칭이 이걸로 줄어듦** — `pre-implementation-audit.md`가 지적했던 "같은 문서 안에서 한쪽은 방어(타입 차단 시도), 한쪽은 무방비 UB"라는 비일관성이, 이제 둘 다 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index d274eea..2b7bab2 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -205,53 +205,73 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데 ## CRUD API 확정 (2026-08-09 세 번째 세션, 1-7 해소) -`get`/`set`은 드롭 — 최종 표면(`Move`/`Swap`은 같은 세션 후속 논의에서 -추가, 아래 "원시 최소화 원칙 정정" 참고): +**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 +인덱스 기준으로 전환.** 원래 "인덱스는 add/remove 반복 시 곧 stale +해진다"는 이유로 레퍼런스 기준을 택했으나, 실사용에서는 반대 문제가 더 +흔함(사용자 지적) — `slot:Add(Frame{...})`처럼 호출부가 리턴값을 변수에 +안 담고 바로 흘려보내는 경우가 많아서, 나중에 그 element를 다시 골라 +Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `Add`만 +새로 넣는 대상이라 자연히 element를 직접 받고, 나머지 CRUD는 전부 +**인덱스 기준**으로 재확정 — 레퍼런스만 갖고 있으면 `IndexOf`로 먼저 +인덱스를 구하면 됨(아래): | 연산 | 시그니처 | 복잡도 | 의미 | |---|---|---|---| | `Add` | `Slot:Add(element, index?)` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 | -| `Remove` | `Slot:Remove(element)` | O(n) | 제거 **+ 파괴**(retract/Destroy) | -| `Extract` | `Slot:Extract(element)` | O(n) | 제거, **파괴 안 함** — 호출부가 소유권 회수, 임의의 다른 Slot에 재삽입 가능 | +| `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(element, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** | -| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 **인덱스**의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** | +| `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와 연결하는 다리 | -- **식별은 기본적으로 element 레퍼런스 기준**(`Add`/`Remove`/`Extract`/ - `Move`) — element는 컴포넌트 호출/Instance 생성이 만들어낸 구체 값이라 - 항상 아이덴티티로 구분 가능. 인덱스 기준으로 하면 add/remove가 반복될 - 때 호출부가 들고 있던 인덱스가 곧바로 stale해짐. -- **`Swap`만 예외 — 인덱스 기준(`indexA`/`indexB`), element 레퍼런스 - 아님(2026-08-09 세 번째 세션 정정).** element 레퍼런스로 받으면 Slot이 - element→index 역방향 맵을 안 갖고 있는 이상 두 element 각각의 현재 - 위치를 찾는 데 O(n)씩(총 2n) 들어서 **`Swap`이 약속하는 O(1)이 깨짐** - — `Move`는 어차피 시프트 자체가 O(n)이라 element 조회 비용이 묻히지만, - `Swap`은 조회 비용이 곧 전체 비용이라 이 차이가 그대로 드러남. 호출부는 - 보통 "지금 몇 번째 항목과 몇 번째 항목을 바꿀지"를 이미 알고 있는 - 상황(예: 드래그 리오더 UI)이라 인덱스로 받는 게 자연스럽기도 함. -- **열거(iteration)/`Get` API는 지금 안 만듦** — `Slot:List(...)`(아래)는 - 자기 자신의 key→element 맵을 따로 들고 있어 Slot의 내부 상태를 조회할 - 필요가 없음. 다른 실사용이 나오면 그때 추가(YAGNI). +- **`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)이면 에러 — 위 "요소 타입 제약" 절. - - `Remove`/`Extract`: 그 element가 지금 이 Slot의 멤버가 아니면 에러. - - `Move`: `element`가 이 Slot의 멤버가 아니면 에러. - - `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖(1..현재 개수)이면 에러 — - 단 `Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op. + - `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는 "가드 확인 + `raw*` 위임"의 얇은 wrapper** — `Add`/ - `Remove`/`Extract`/`Clear`/`Move`/`Swap` 전부 `self._listed`(`:List`가 - 설치돼 있으면 수동 CRUD 금지)만 확인하고 실제 로직은 `rawAdd`/ - `rawRemove`/`rawExtract`/`rawClear`/`rawMove`/`rawSwap`에 있음 — 이 - `raw*` 함수들이 `:List`의 reconcile이 가드 없이 직접 호출하는 바로 - 그 함수(아래 "`Slot:List`" 절의 "구현" 참고). 공개 메소드에 로직이 - 따로 있는 게 아니라 전부 이 한 세트를 공유. +- **공개 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 버그로 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index eb01544..5694f63 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -71,6 +71,7 @@ function TagHandler.process(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) @@ -84,11 +85,16 @@ end 전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시 이전 값과 diff. - **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`가 - 불림 — **`v`를 굳이 안 봐도 됨**: retract는 구조상 "더 이상 Tag가 - 아니게 됐을 때만" 불리므로, 뭐가 새로 들어왔든 전체 삭제가 항상 맞는 - 동작. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를 받는 건 계약 - 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가 그걸 필수로 - 요구해서가 아님. + 불림. **[명시화, 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` 저장분)만 정리하면 됨. 상세 메커니즘은 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index cee3d1c..511b121 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -63,6 +63,17 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 자동 생성된 자식은 기존 관례대로 `_`/`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`이기만 하면 아무거나) 재사용하지 diff --git a/.claude/question.md b/.claude/question.md index 26307ea..64c09df 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -216,12 +216,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. -- **Attribute 특수 키 타입 파라미터화** — `base/bind-system-plan.md` - "Attribute 특수 키" 절(2026-08-06 신규). `[Attribute<> "name"]` - 제네릭 스타일 vs `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 - 중 뭘로 갈지 — 소견은 DI 인스턴스 생성 패턴처럼 "제네릭 하나 + 자주 - 쓰는 타입만 정적 지름길" 절충이지만 확정 아님, M10(Handlers/Attribute) - 착수 전 아무 때나 확인해도 됨. +- **[해소됨, 2026-08-09 열한 번째 세션]** Attribute 특수 키 타입 + 파라미터화 — `[Attribute<> "name"]` 제네릭 스타일과 + `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 **둘 다 채택으로 + 확정**(내부 구현 동일, 호출부 표기만 다름). `base/attribute-plan.md` + 참고 — 제네릭 파라미터가 `=` 뒤 값 타입까지 좁혀주는지는 M0/M10에서 + 실측 필요(안 돼도 런타임엔 영향 없음). - **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`** — `research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴). 방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy diff --git a/.claude/reference/comparison-charm.md b/.claude/reference/comparison-charm.md new file mode 100644 index 0000000..1a690d8 --- /dev/null +++ b/.claude/reference/comparison-charm.md @@ -0,0 +1,135 @@ +# charm(littensy/charm) 비교 — quad-v2 설계 근거 + +**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. quad에 관한 결정 +자체가 아니라 charm 리서치 스냅샷(2026-08-09, `.claude/initreq/charm`에 +새로 클론)이라 항상 읽어야 하는 base 컨텍스트는 아님 — Fusion/Vide 비교와 +같은 성격, `quadnomicon` 소재 후보이기도 함. quad-v2의 Blocker/Effect/ +Slot:List/(미래) 네트워크 동기화 설계에 근거로 인용될 때만 열어볼 것, +실제 확정 사항은 인용하는 쪽 `base/` 문서가 소스. + +**charm이 뭔지**: Roblox용 Zustand류 상태관리 라이브러리 — +`atom`/`computed`/`subscribe`/`effect`/`batch` 핵심(`packages/charm/src/ +init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) + +`react-charm`/`vide-charm`(얇은 어댑터). 코어는 실제로 절반쯤이 alien-signals +포크(`system.luau`, dirty/pending 비트플래그 전파 엔진, 237줄 — 가장 큰 +테스트 파일이 이걸 검증하는 `topology.test.luau` 484줄)라 순수 서핏보다 +알고리즘 실체가 있지만, quad는 노드/의존성 재사용 모델 자체를 안 쓰기로 +이미 갈라섰으므로 이 부분은 이식 대상이 아님. + +## 반면교사 — quad가 이미 기각/확정한 것과 충돌하는 부분 + +- **`batch(fn, ...)`가 quad가 이미 기각한 `Batch` 렉시컬 블록과 구조적으로 + 동일.** `init.luau:768-778`이 `startBatch`/`endBatch`(`init.luau:285-296`)로 + 콜백을 감싸 effect flush를 지연시키는 모듈 전역 `batchDepth` 카운터 + 방식(`init.luau:66`) — `archive/batch-rejected.md`가 "코루틴 yield에 + 안전하지 않다"는 이유로 기각한 것과 정확히 같은 모양. **charm 자신도 이 + 위험을 인정하는 증거를 갖고 있음**: `wrapUserSpace()`(`init.luau:100-129`)가 + signal/effect/batch 콜백을 `coroutine.create`/`resume`으로 감싸서 콜백 도중 + yield를 시도하면 에러내는 가드(`flags.strict`, Studio 기본 on, + `init.luau:71-81`)를 따로 둠 — 위험을 런타임 가드로 땜질한 것이지 없앤 게 + 아님. quad는 원시 자체를 제거하는 쪽을 택했으니(`Blocker`가 그 자리를 + 대신함, `base/blocker-plan.md:25-44`) 이 모양을 참고할 이유 없음. +- **`atom()`의 getter/setter 겸용 콜러블이 quad가 `Store`에서 이미 기각한 + 대입 문법과 같은 트레이드오프.** `atom(initialValue, equals?)` + (`init.luau:519-527`)가 인자 개수로 read/write를 분기하는 방식 — + `store.key = value`를 버리고 `store.key:Set(value)`로 간 이유 + (`base/store-semantics.md:208-233`, 읽기/쓰기 타입 비대칭)와 같은 문제. + charm 스스로도 README(185-196행)에서 `atom()`을 `signal()`(진짜 get/set + 쌍) 위에 얹은 편의 sugar로 취급 — charm 안에서도 "진짜 1급 형태는 아니다"로 + 다뤄지는 걸 참고. +- **Effect가 전혀 GC-native가 아님 — 전부 수동 dispose 필요.** `effect`/ + `effectScope`/`listen`/`subscribe` 전부 호출자가 직접 불러야 하는 + `Cleanup` 함수를 반환(`init.luau:607-641`, `652-676`, `800-835`) — Roblox + Instance 라이프타임에 자동으로 묶이는 경로가 코어에 아예 없음. `base/ + lifecycle-pattern.md`의 GC-native 원칙과 정반대 축. 오히려 `gc.test. + luau:19-33`의 코멘트가 "스코프 밖에서 `computed()`를 그냥 부르면 의존성에 + 대한 영구 강참조가 생겨서 `effectScope`로 감싸 명시적으로 풀어줘야 + 한다"는 걸 테스트 자체가 우회 헬퍼(`unlink()`, 29-33행)로 증명함 — + 이건 quad의 GC-native 가정을 **뒷받침하는** 증거가 아니라, "레퍼런스/ + 클로저 기반 반응 그래프가 자동으로 안 치워질 수 있다"는 **반례**로 + 인용할 것(rbvm이 "실물 검증된 근거"로 인용되는 것과 반대 방향 — 나중에 + quad의 GC-native 가정을 스트레스테스트할 때 이 케이스를 참고). +- **`computed()`의 값-동등성 억제가 기본값이자 암묵적, opt-in이 아님.** + `updateComputed`가 `oldValue ~= newValue`(`init.luau:302-321`, 특히 + 317행)를 리턴하고 signal setter도 `equals`가 없으면 `node.pendingValue ~= + value`로 기본 비교(`init.luau:489`) — charm의 모든 atom/computed가 기본으로 + 값 비교 억제를 함. quad가 나중에 Blocker에 인접한 "값 안 바뀌면 자동 + 스킵" 기본값을 도입하고 싶어질 때, charm처럼 **모든 노드에 암묵적으로** + 거는 방식은 `Blocker`가 이미 명시한 "특정 게이트 지점에서만 opt-in" + 원칙(`base/blocker-plan.md:65-68`)과 "Source는 스스로를 자동 변형하지 + 않는다"는 `store-semantics.md` 기조에 둘 다 어긋남 — 반면교사로 남겨둘 것. + +## 참고할만한 부분 + +- **charm의 `None` 센티널이 quad 자신의 것을 독립적으로 재확인해줌.** + `patch.luau:10,19-30`이 diff 페이로드에서 "안 바뀜"과 "명시적으로 + 지움"을 `nil`로는 구분 못 해서 `None = {__none="__none"}`을 따로 + 둔 이유 — quad의 배열/해시 파트 `None` 센티널 정당화(`base/ + bind-system-plan.md:180-266`)와 동기 없이 같은 결론에 수렴한 사례. + 새 아이디어는 아니고 인용 근거로만 가치 있음. +- **quad가 미결로 남긴 "previous 값 비교" 문제에 대한 두 가지 답.** + (1) `signal(initialValue, equals?)`(`init.luau:432`, `Equals` 타입은 + 23행)는 생성 시점에 `initialValue`를 항상 요구해서 "비교할 이전 값이 + 아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰 — + `research/additional-primitives-plan.md`가 남겨둔 "비교할 이전 값이 + 확정 안 된 문제"에 대한 한 가지 해법 형태. (2) `computed(getter)`가 + getter에 **이전 계산 결과**를 인자로 넘겨줌(`init.luau:538`, + `(previousValue: T?) -> T`, README 276-287행, `computed.test. + luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad의 + `store-semantics.md:280-284`가 이미 띄워둔 "`:Compute(fn)`에 선택적 + 두 번째 `previous` 인자" 안과 거의 동일한 모양. 새로 수입할 아이디어가 + 아니라 **이미 검토 중인 안이 실제로 동작한다는 정황 증거**로 인용 + 가치 있음. +- **charm-sync의 diff/patch 메커니즘 — quad가 아직 전혀 안 다뤄본 영역이라 + 가장 새로운 참고자료.** `patch.luau:59-89`(`diff`)가 재귀적 구조적 + diff로 중첩 patch 테이블을 만들고, `apply`/`applyMutable` + (`patch.luau:91-131`)이 immutable 재구축(레벨마다 `table.clone`, 순수 + signal용)과 in-place mutate+`:Emit()`류 변형(반응형 프록시용) 둘 다 + 제공 — quad가 이미 다른 이유로 갖고 있는 clone-vs-mutate+`Emit` 분리 + (`base/store-semantics.md:240-284`)와 우연히 같은 모양. `patch. + luau:32-57`(`stringifySparseArray`)는 실전에서 놓치기 쉬운 페이로드 + 함정을 문서화함 — RemoteEvent/JSON 직렬화가 성긴 배열의 trailing hole을 + 조용히 드롭해서, 보낼 땐 문자열 키로 재인코딩하고 받을 땐 숫자 키로 + 복원해야 함(`patch.luau:101-107`). `server.luau`는 클라이언트별 관심사 + 필터링을 하나의 전역 diff 위에 구현(`clients` 테이블의 + `PENDING_INITIAL_STATE`/`LISTENING_FOR_CHANGES` 상태, 27-32행, + `selectFromGlobalPatch` 209-250행) + 모든 중간 변경을 보존하는 opt-in + 모드(`config.preserveHistory`, `diffGlobalUpdateBuffer`, 124-133행) vs + 기본값인 flush당 diff 하나로 합치는 모드(`diffGlobalState`, + 192-207행) — `Blocker`가 일반화하는 coalescing 트레이드오프의 손으로 짠 + sync 전용 구현체. 지금 스코프 밖이지만 나중에 quad가 네트워크 복제 + 설계를 시작하면 첫 참고 지점으로 쓸 것. +- **`observe()`의 엣지케이스 테스트 스위트가 `Slot:List` 테스트 체크리스트로 + 재사용할 만함.** `observe.test.luau`가 마운트 콜백 도중의 재귀적 + add/remove(92-113행), 자기 마운트 도중 자기 자신 제거(115-132행), add/remove + 도중 dispose(134-168행), 재귀적 업데이트 중 에러가 reconciler를 안 멈추게 + 하는지(170-196행)를 검증 — `observe()` 자신의 메커니즘(키별 + `effectScope`, `init.luau:851-898`)은 quad가 채택한 방식이 아니지만, + 테스트 항목 목록 자체는 `base/slot-plan.md`의 키 기반 재조정을 실제 + 구현할 때 대조 체크리스트로 쓸 가치가 있음. + +## 종합 + +코어(atom/computed/effect/subscribe/batch, `init.luau`의 절반쯤)는 평범한 +시그널 라이브러리라 quad가 이미 확정한 것을 대체로 재진술할 뿐이고, 세 +군데(`batch()`, `atom()`, 수동 dispose Effect)는 오히려 quad가 이미 능동 +기각한 패턴을 그대로 구현하고 있음 — 사용자가 애초에 예상한 "짧은 +라이브러리라 새로운 게 없을 것"이 이 레이어에는 대체로 맞음. 진짜 참고 +가치는 코어 밖에 있음: charm-sync의 diff/patch(현재 quad 스코프 밖이지만 +새 영역), 그리고 quad가 미결로 열어둔 Blocker의 "previous 값 비교" 문제에 +대한 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`의 +previous-in-getter). 지금 당장 base 문서를 고칠 만한 발견은 없음 — 순수 +참고자료로 등록. + +**인용 위치**: `packages/charm/src/init.luau:66,71-93,100-129,285-296, +302-321,432,489,519-527,538,607-641,652-676,768-778,800-835,851-898` · +`packages/charm/src/system.luau`(전체, alien-signals 포크) · +`packages/charm/test/gc.test.luau:9-33` · `packages/charm/test/ +computed.test.luau:84-104` · `packages/charm/test/observe.test.luau:92-196` · +`packages/charm-sync/src/patch.luau:10,19-30,32-57,59-89,91-131` · +`packages/charm-sync/src/server.luau:27-32,124-133,192-207,209-250` · +`README.md:185-196,262-287` · `base/store-semantics.md:208-233,240-284` · +`base/blocker-plan.md:25-44,65-68` · `base/lifecycle-pattern.md`(GC-native +원칙) · `archive/batch-rejected.md` · `base/bind-system-plan.md:180-266` +(None 센티널) · `research/additional-primitives-plan.md`(Blocker/키 기반 +컬렉션 미결 상태). diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index aa6467c..e75ee54 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -73,7 +73,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 - skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약 ### component-composition-plan.md / module-lifecycle-plan.md -- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `props.Modifier`/`props.Ref` named parameter 경계 전달(**`props.Modifier or None`/`props.Ref or None` 필수 관용구 — 안 쓰면 nil-hole 버그, 2026-08-07 열 번째 세션 확정**) / `InitRoblox(Module)` 팩토리 초기화 +- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티 바인딩(**[정정, 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 네이밍 미정 등 열린 질문 메모 @@ -220,14 +220,26 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: ## 5. 문서화 아직 보류(미확정 설계라 쓰면 안 됨) -- Slot 형제 순서 보장 (`slot-plan.md`) +**[정정, 2026-08-09 열한 번째 세션] 아래 목록 중 상당수가 이미 해소돼 +있었음 — 이 절이 오래 안 갱신되며 stale해진 것, 실제 열린 것만 남기고 +해소된 건 표시만 남김(중복 조사 방지 목적, 지웠다가 나중에 또 조사하게 +되는 걸 막기 위해 흔적만 유지).** + +- **[해소됨]** Slot 형제 순서 보장 — `Dispatch.setLength`/ + `setOffsetSource`(Length/Offset)로 2026-08-09 여섯 번째 세션에 확정, + `bind-system-plan.md` "Length/Offset" 절 참고. - Tween 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름, 트윈 옵션 값 - 모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) -- `Attribute` 제네릭 vs 타입별 정적 생성자 (`bind-system-plan.md`) -- provider/processor 네이밍 (`module-lifecycle-plan.md`) -- 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 - 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ - additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) + 모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) — 아직 열림. +- **[해소됨]** `Attribute` 제네릭 vs 타입별 정적 생성자 — 2026-08-09 + 열한 번째 세션에 "둘 다 채택"으로 확정, `base/attribute-plan.md` 참고. +- provider/processor 네이밍 — **[해소됨]** `Handler`로 이미 오래전 확정 + (`base/module-lifecycle-plan.md`), 이 줄이 그 갱신을 놓치고 있었음. +- **[해소됨]** 키 기반 동적 컬렉션 재조정 최종 이름/시그니처, `Slot:Extract` + 세부 시맨틱 — `Slot:List(data, updateFn, keyFn?)`로 2026-08-09 세 번째 + 세션에 전부 확정·통합(`base/slot-plan.md`), `Extract`도 CRUD 표에서 + 완전히 확정(2026-08-09 열한 번째 세션엔 `Extract(index, newElement?)`로 + 더 확장). `research/additional-primitives-plan.md`는 더 이상 열린 + 항목 없음, 배경 자료로만 유지. - **"hook"/"pre-hook" 용어 채택 여부 + `PreRef`의 취소 가능성** (2026-08-07, 위 심화 후보 6번 참고) — `bind-system-plan.md`는 `PreRef`가 위치 무관 호이스팅이라는 것과 일반 `Ref`가 우선순위 스캔에 참여한다는 것까지는 diff --git a/CLAUDE.md b/CLAUDE.md index 564957b..e6b6184 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2306,3 +2306,123 @@ lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed` **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-09 여덟 번째 세션 — `.claude/base/` 전체 중간검토(질문 모드), +실제 설계 결함 다수 발견·수정 + +사용자가 "이 프로젝트의 계획을 중간검토합니다. 각 요소들에 대해서 함수나 +클래스 등의 동작을 제가 확인 가능하게 리스팅해요... 질문 모드를 쓰면 +좋겠습니다"라고 요청 — 2026-08-04 6차 라운드 때 예고해뒀던 "다음 세션 +검증 패스"를 실제로 실행한 세션. 서브에이전트 6개를 병렬로 띄워 +`.claude/base/` 전체(15개 파일, 5296줄)를 클러스터별로 정독시켜 확정된 +API/동작을 file:line 인용과 함께 그라운딩된 리스팅으로 뽑아낸 뒤, 6개 +배치(Store/State/Source+Dispatch, Ref/PreRef+Brand+Length-Offset+생명주기, +Modifier, Slot, Tag/Attribute/UI shorthand+Blocker/Effect, 컴포넌트 +경계+아키텍처)로 나눠 각 배치를 텍스트로 보여주고 바로 `AskUserQuestion` +(문제없음/문제있음)으로 확인받는 방식으로 진행 — 문제 제기된 건 그 +자리에서 바로 문서에 반영(끝까지 미루지 않음). 총 24개 확인 질문 중 +약 1/3에서 실제 설계 결함이 나옴 — 전부 사용자가 구체적인 반례/Luau +시맨틱스를 근거로 지적한 것이라 전부 그대로 수용, 방어하지 않고 수정. + +**발견·수정된 것 (파일별)**: + +- **`base/bind-system-plan.md`** (가장 많이 고침): + - `Source(default)`/`Ref(default)`의 `default` 생략이 "선택"이라는 + 서술에 "`T`가 nilable일 때만 안전하다"는 캐비엇 누락 — 추가. + `Ref`는 `:Callback`이 등록 즉시 발화해서 이 문제가 더 잘 드러남. + - Dispatch 체인 절에 "`handler.process`를 `Dispatch.process` 없이 + 직접 호출하면 UB(체인 bookkeeping이 깨져 `retract`가 영영 안 + 불리거나 정합성이 무너짐)"라는 불변식이 안 적혀 있었음 — 추가. + - **Ref 콜백/대기자 배열의 소진 슬롯을 `None`에서 `nil`로 되돌림** — + 2026-08-07 열 번째 세션에 "구멍 있는 정수 키는 순회 순서가 깨진다"는 + 이유로 `None`으로 바꿨던 게 이 배열엔 안 맞는 처방이었음(사용자 + 지적): 이 배열은 순서가 안 중요해서 일반화 `for`가 구멍이 있어도 + 전부 방문하고, 오히려 `None`을 쓰면 슬롯이 영원히 안 비어서 + `:Wait()`마다 배열이 끝없이 길어지는 새 문제가 생김 — `nil`로 + 지우고 빈 슬롯을 재사용하는 등록 함수로 바꿈. PreRef pre-pass/ + Length-Offset의 `sourceList`는 순서가 실제로 중요해서 계속 `None`이 + 맞음 — 두 사례를 헷갈리지 않게 교차 참조로 명확히 구분. + - `.Value`가 평범한 hash 필드가 아니라 `__index`로 구현돼야 하는 + 이유(콜백 배열과 같은 테이블에 있으면 `T`가 함수/스레드일 때 콜백 + 처리 루프에 오분류될 위험) 추가. + - **`isRef`/`isPreRef`를 `isState`/`isSource`와 같은 상위-하위 합성 + 패턴으로 재정정** — 원래 "서로 배타적인 형제 브랜드"였는데, `Source`가 + `State`를 만족하듯 `PreRef`도 `Ref` 런타임을 재사용하는 관계라 + 같은 방향(하위=PreRef가 상위=Ref에 포함)으로 다뤄야 일관적이라는 + 지적 — `isPreRef`가 가장 구체적인 항등, `isRef`는 그 위에 얹힌 + 상위 개념. `(v=Ref)` children leaf 매치 핸들러는 이제 + `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 함. + - `NoneHandler`가 `k` 타입을 안 가리는데 왜 배열 파트 `None`(숫자 + 키)에 실제로 안 걸리는지 명확화(배열 파트 `None`은 애초에 + `Dispatch.process`를 안 타서 `NoneHandler`가 볼 기회 자체가 없음). + - `setLength`/`setOffsetSource`의 `None` 페어링 대상을 "Ref/PreRef + 등" 예시 목록에서 "그 배열 위치의 값 자체가 `None`인 모든 경우"로 + 명시적으로 확장, 둘이 항상 짝을 맞춰야 한다는 점도 재강조. + - `:Subscribe()`가 quad 전역 GC-native 원칙의 의도적 예외(참조를 + 다 놓아도 GC 안 되고 계속 실행됨)라는 경고가 없었음 — 추가, 용도도 + "완전히 top-level" 케이스로 좁혀 문서화. +- **`base/modifier-plan.md`**: 핸들러 계층 값 → error 체크가 `State`류 + "State/Source가 감싼 내부 값"까지는 못 잡는다는 한계 — 명시적 UB로 + 문서화(오버엔지니어링 방지, 실사용 위험 낮음). +- **`base/slot-plan.md`** (가장 큰 변경): **CRUD 식별 기준을 element + 레퍼런스에서 인덱스 기준으로 전환** — `Remove(index)`/ + `Extract(index, newElement?)`/`Move(oldIndex, newIndex)`. 원래 + "인덱스는 stale해진다"는 이유로 레퍼런스 기준을 택했는데, 실제로는 + 반대(호출부가 `Add` 리턴값을 안 담고 흘려버리는 경우가 흔함)가 더 + 큰 문제였음. **`ExtractAll()`/`Get(index)`/`IndexOf(element)` 신설** + (`Get`은 "YAGNI"로 드롭했던 걸 재추가). **`Extract(index, newElement?)` + 신설** — 교체가 필요하면 기존엔 Extract+Add 이중 O(n) 시프트가 + 필요했는데, 이제 O(1) 제자리 교체 가능(이전 element 반환). +- **`base/tag-plan.md`**: `TagHandler.retract`의 전체 삭제 동작이 + 정확히 `v == nil`일 때만 맞다는 전제를 `assert`로 명시(기존엔 "v를 + 안 봐도 됨"이라고만 서술돼 있어 조건이 암묵적이었음). +- **`base/attribute-plan.md`**, **`.claude/question.md`**: 타입 + 파라미터화(`Attribute<>` 제네릭 vs `BooleanAttribute`류 정적 + 패밀리) — "미확정"에서 **"둘 다 채택"으로 확정**(내부 구현 동일, + 호출부 표기만 다름). `=` 뒤 값 타입까지 narrowing되는지는 M0/M10 + 실측 필요(안 돼도 런타임 무관)로 명시. +- **`base/ui-shorthand-plan.md`**: `UICorner`/`UIPadding`/`UIScale`이 + 타입 생성 스크립트가 만드는 `FrameModifier`류 타입의 메소드 목록에도 + 포함돼야 한다는 체크리스트 항목 추가(런타임과 무관한 순수 타입 + 생성 디테일). +- **`base/effect-plan.md`**: `EffectHandle`이 내부 Observer를 필드로 + 강참조한다는 것, `bindLifetime`/`:Subscribe()` 둘 다 `state`가 있으면 + 내부 Observer까지 cascade해야 한다는 것(안 그러면 내부 Observer의 + `canExecute` 게이팅이 올바른 `inst`를 못 봄) — 재확인 후 명시화. +- **`base/component-composition-plan.md`** (Length/Offset 다음으로 많이 + 고침): + - **"리프 바인딩엔 Source가 좁은 예외"라는 서술이 틀림 — 정정.** + `local a = Source(true); Frame { Visible = a }; a:Set(false)`처럼 + Source를 리프에 직접 물리는 건 이미 확정된 "Source가 State를 + 구조적으로 만족" 원칙이 그대로 커버하는 정상 경로였음 — "State가 + 일반적"이라는 서술은 Source를 못 쓴다는 뜻이 아니라 "여러 값에서 + 파생된 계산 결과는 State일 수밖에 없다"는 통계적 경향 서술일 + 뿐이라고 재정정. + - `props.Modifier or None` 관용구의 `None` 근거 포인터가 Ref 콜백 + 배열 정정으로 깨질 뻔한 걸 교차 참조로 바로잡음(그 배열은 순서가 + 중요한 별개 케이스라 `None`이 계속 맞음). + - `Frame { Comp{} }`에서 `Comp`가 `Slot`을 반환하는 다중 루트 우회 + 경로가 새 배선 없이 그대로 작동함을 재확인(값이 컴포넌트 호출로 + 왔든 리터럴이든 디스패치 입장에선 구분 없음). +- **`ROADMAP.md`**: 위 `Ref` `None`→`nil`/`isRef`·`isPreRef` 변경사항 + 체크박스 동기화. + +**변경 없이 확인만 된 것**: `:With`/`:Compute` 체이닝, `None` 센티널 +기본 메커니즘, Length/Offset 전체, 이중 바인딩 금지/`Relate`/생명주기, +Modifier setter/Apply/Overridden 판단 기준, `Peek`/`isState`/`None` +setter 인자, Slot 요소 타입 제약/Extract portal/`Length`, `Slot:List` +시그니처(단, 캐스케이드 성능 이슈는 `keyFn` 명시 유도로 이미 문서화돼 +있어 추가 조치 불필요), List 구독 lazy 시점, Tag 값 모양/패키지 배치, +Blocker 전체, 소스트리/네이밍 컨벤션/Handler 3분류/테스트 전략/이식성 +원칙. + +**부수 기록**: `.claude/memory`(세션 간 영속 기억)의 협업 스타일 메모에 +이번 리뷰 진행 방식(에이전트 병렬 추출 → 배치별 텍스트+AskUserQuestion +즉시 확인 → 그 자리에서 바로 문서 반영)을 다음에 재사용할 패턴으로 +기록 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션은 설계 +확정이 아니라 기존 확정 사항의 결함 수정이었지만, 결과적으로 M0 착수 +전 상태가 더 탄탄해졌을 뿐 우선순위 자체는 그대로. 이 중간검토가 +마지막 배치(6단계)까지 끝났는지, 사용자가 이어서 더 볼 부분이 있는지는 +다음 세션 시작 시 확인. diff --git a/ROADMAP.md b/ROADMAP.md index b850aeb..db42293 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,8 +82,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/ `isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 - 태그 목록에서 빠져있었음, `isRef`/`isPreRef`는 단순 항등이지 - `isState`처럼 집합 멤버십 아님) 전부의 기반. `isNone`만 예외로 + 태그 목록에서 빠져있었음. **[정정, 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`) — @@ -195,21 +200,31 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State`도 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. -- [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론 - 확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지 - 전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 - throw"도 `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 - 기준이 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절). - `Move(element, newIndex)`(O(n))/`Swap(indexA, indexB)`(O(1), element - 아닌 인덱스 — element면 위치 조회에 2n 들어 O(1) 약속이 깨짐)은 - 리오더 전용, Parent를 안 건드림. 공개 6개 메소드 전부 "가드 확인 + - `raw*` 위임" 얇은 wrapper. base/roblox 경계에 mount/unmount 외 - reposition 훅 추가됨. **`Slot()` 제네릭화, 요소 타입 제약 확정** - — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 - `T`만), 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier)은 - self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(`Modifier` - 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = Slot<>`가 - quad-roblox의 사실상 유일한 Slot 타입. +- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ + `Get`/`IndexOf` CRUD 의미론 확정** (2026-08-09 세 번째 세션, 2026-08-09 + 열한 번째 세션에 식별 기준 재정정) — 에러 조건까지 전부 확정 + (`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도 + `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이 + 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절). + **[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 + 인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)` + (O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA, + indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접 + 받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을 + 안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안 + 맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를 + 구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?` + 신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)` — + `newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔 + 교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개 + mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/ + `IndexOf`는 순수 읽기라 가드 대상 아님). base/roblox 경계에 + mount/unmount 외 reposition 훅 추가됨. **`Slot()` 제네릭화, 요소 + 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 + 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ + Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 + error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = + Slot<>`가 quad-roblox의 사실상 유일한 Slot 타입. - [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, `keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프). @@ -294,8 +309,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 우회한 버그라는 뜻 — 같은 절 참고 - [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 - `coroutine.resume(v, self)`+`None`으로 소진(`nil` 아님 — - 2026-08-07 열 번째 세션 정정, `#t`/`table.insert` 안전성), 함수면 + `coroutine.resume(v, self)`+`nil`로 소진(2026-08-09 열한 번째 + 세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 `None`이 아닌 + `nil`, `table.insert` 대신 빈 슬롯 선형 탐색 등록), 함수면 `v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`는 `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` From df4a77b02df680b43de7afad26261cb72a0f7408 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 23:55:22 +0900 Subject: [PATCH 58/62] =?UTF-8?q?docs(luau-test):=20M0=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=EA=B2=80=EC=A6=9D=20=EC=8A=A4=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=81=AC=20=EC=8B=A0=EC=84=A4,=20luau-ignoreme=E2=86=92.claude?= =?UTF-8?q?/luau-test=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base/ 확정 사항 중 아직 실제 Luau로 부딪혀본 적 없는 것(M0 스파이크 대상)을 사용자가 luau/luau-analyze/luau-lsp/Roblox Studio로 직접 돌려볼 수 있는 독립 실행 스크립트 14개 + README 색인으로 정리. 커밋 f198fd9의 정정사항(Ref 콜백/대기자 배열 소진을 None에서 nil로 되돌린 것 등)을 반영해 02번을 재작성했고, 타입 관련 실측이 필요한 항목 (Attribute 제네릭 DI 키 narrowing, Ref/PreRef 구조적 서브타입, Source/ Ref nilable-default 오버로드)을 새로 찾아 12~14번으로 추가함. 처음엔 git 자동 제외 폴더(luau-ignoreme/)에 만들었으나 커밋해서 레포에 남기기로 해 .claude/luau-test/로 이동, .claude/README.md에 색인 추가. CLAUDE.md에 세션 요약 반영 — 아직 실행 결과는 미확인. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 1 + .../01-two-pass-array-hash-order.luau | 60 ++++ .../02-none-sentinel-vs-nil-holes.luau | 172 ++++++++++ .../03-recursive-store-bind-dispatch.luau | 168 ++++++++++ .../04-dispatch-chain-retractUnder.luau | 201 ++++++++++++ .../05-store-state-diamond-propagation.luau | 157 +++++++++ .../06-component-boundary-nil-hole-props.luau | 87 +++++ .../luau-test/07-relate-weak-table-gc.luau | 135 ++++++++ .../08-type-source-satisfies-state.luau | 78 +++++ .../09-type-modifier-overridden-subtype.luau | 73 +++++ .../10-roblox-studio-checks.server.luau | 206 ++++++++++++ .../11-modifier-illegal-value-error.luau | 302 ++++++++++++++++++ ...-type-attribute-generic-key-narrowing.luau | 93 ++++++ .../luau-test/13-type-ref-preref-subtype.luau | 135 ++++++++ .../14-type-nilable-default-overload.luau | 84 +++++ .claude/luau-test/README.md | 118 +++++++ CLAUDE.md | 68 +++- 17 files changed, 2137 insertions(+), 1 deletion(-) create mode 100644 .claude/luau-test/01-two-pass-array-hash-order.luau create mode 100644 .claude/luau-test/02-none-sentinel-vs-nil-holes.luau create mode 100644 .claude/luau-test/03-recursive-store-bind-dispatch.luau create mode 100644 .claude/luau-test/04-dispatch-chain-retractUnder.luau create mode 100644 .claude/luau-test/05-store-state-diamond-propagation.luau create mode 100644 .claude/luau-test/06-component-boundary-nil-hole-props.luau create mode 100644 .claude/luau-test/07-relate-weak-table-gc.luau create mode 100644 .claude/luau-test/08-type-source-satisfies-state.luau create mode 100644 .claude/luau-test/09-type-modifier-overridden-subtype.luau create mode 100644 .claude/luau-test/10-roblox-studio-checks.server.luau create mode 100644 .claude/luau-test/11-modifier-illegal-value-error.luau create mode 100644 .claude/luau-test/12-type-attribute-generic-key-narrowing.luau create mode 100644 .claude/luau-test/13-type-ref-preref-subtype.luau create mode 100644 .claude/luau-test/14-type-nilable-default-overload.luau create mode 100644 .claude/luau-test/README.md diff --git a/.claude/README.md b/.claude/README.md index d2e26be..748ba9b 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -15,6 +15,7 @@ | `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/`로 승격(또는 구현 착수 시 diff --git a/.claude/luau-test/01-two-pass-array-hash-order.luau b/.claude/luau-test/01-two-pass-array-hash-order.luau new file mode 100644 index 0000000..961c42e --- /dev/null +++ b/.claude/luau-test/01-two-pass-array-hash-order.luau @@ -0,0 +1,60 @@ +--[[ + 검증 대상: base 디스패치 드라이버가 명시적으로 강제하는 + "배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중" 두 패스 순회 계약. + + 배경: .claude/base/bind-system-plan.md "props 순회 순서" 절, ROADMAP.md M0 4번째 항목. + 사용자가 이미 Luau REPL로 `for i,v in {a=1, 2, b=3} do ... end`가 + `1, 2` 다음 `a, 1` `b, 3` 순서로 나오는 걸 확인했었지만(우연한 관찰), + base는 이 우연한 동작에 기대지 않고 배열 파트(1..#t)를 먼저, 그 다음 + 별도로 해시 파트만 골라내는 두 패스를 "명시적으로" 강제하기로 확정함 + — 이 스크립트는 그 강제 버전이 실제로 계약대로 동작하는지 확인. + + 실행: `luau 01-two-pass-array-hash-order.luau` (Roblox 필요 없음, 순수 CLI) + 기대 결과: "array pass"가 항상 "hash pass"보다 먼저 전부 출력되고, + array pass 안에서는 index 순서(1,2,3...)가 정확히 지켜져야 함. +]] + +local function isArrayKey(k) + return type(k) == "number" and k == math.floor(k) and k >= 1 +end + +-- Dispatch.drive(inst, flattened)의 최소 스파이크 버전 +local function drive(inst, flattened) + -- pass 1: 배열 파트, index 순서 보장 + local n = #flattened + for i = 1, n do + local v = flattened[i] + print(string.format("[array pass] inst=%s i=%d v=%s", tostring(inst), i, tostring(v))) + end + + -- pass 2: 해시 파트, 배열 인덱스(1..#t)는 건너뜀 + -- 주의: pairs()/제네릭 for는 배열 파트도 다시 순회하므로 반드시 걸러내야 함 + for k, v in flattened do + if not (isArrayKey(k) and k <= n) then + print(string.format("[hash pass] inst=%s k=%s v=%s", tostring(inst), tostring(k), tostring(v))) + end + end +end + +local children = { "Ref1", "Child2", "Child3" } +local props = { + children[1], + children[2], + children[3], + Name = "TestFrame", + BackgroundTransparency = 0, + Event_Activated = "handler", +} + +print("=== two-pass order 검증 ===") +drive("FakeInstance", props) + +--[[ + 추가로 확인할 것 (실행 후 눈으로 확인): + 1. array pass 3개가 hash pass보다 먼저, 그리고 i=1,2,3 순서로 나오는가? + 2. hash pass에 array 항목(children)이 중복으로 안 섞여 나오는가? + 3. 테이블 리터럴에서 해시 키를 적는 소스 텍스트 순서를 바꿔도(Name/ + BackgroundTransparency/Event_Activated 순서를 바꿔서 재실행) + array pass 결과가 그대로인지 확인해볼 것 — 순서가 소스 텍스트가 + 아니라 오직 "배열/해시 파트 분리"에만 의존한다는 걸 재확인하는 목적. +]] diff --git a/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau b/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau new file mode 100644 index 0000000..a341f98 --- /dev/null +++ b/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau @@ -0,0 +1,172 @@ +--[[ + 검증 대상: 배열 슬롯을 "소진"시킬 때 nil로 지울지 None 센티널로 지울지는 + 그 배열의 성격(순서가 중요한가, 슬롯 재사용이 필요한가)에 따라 갈린다는, + 2026-08-09 열한 번째 세션에 재정정된 최종 결론. + + **[중요, 2026-08-09 커밋 f198fd9 반영]** 이 파일의 이전 버전은 + "Ref 콜백/대기자 배열도 None으로 소진해야 한다"고 잘못 적어뒀었음 — + 사용자가 직접 찾아낸 버그: None으로 소진하면 그 슬롯이 영원히 + non-nil로 남아있어서, `:Wait()`/`:Callback()`가 반복 호출될 때마다 + 배열이 끝없이 길어지는(예전 소진 슬롯을 재사용 못 하는) 진짜 버그였음. + .claude/base/bind-system-plan.md "왜 None이 아니라 nil인가" 절(2026-08-09 + 열한 번째 세션, 최종 정정)이 최신 소스 — 결론은 두 패턴이 서로 다른 + 문제를 풀고 있었다는 것: + + - **순서가 중요한 배열(PreRef pre-pass 소진 슬롯, Length/Offset의 + sourceList)**: 계속 `None` — 구멍이 생기면 해시 파트로 밀려 + index 순회 순서가 깨지므로, "채워짐"을 유지해야 함. + - **순서가 안 중요하고 슬롯 재사용이 필요한 배열(Ref 콜백/대기자 + 리스트)**: `nil` + "빈 슬롯을 선형 탐색해 재사용"하는 등록 함수 + (`table.insert`는 안 씀 — 구멍 있는 테이블에서 `#t`가 미정의 + 동작이라서). 순서 자체는 문제 안 됨 — 일반화 `for`는 구멍이 있어도 + 모든 non-nil 엔트리를 빠짐없이 방문하기 때문. + + 이 스크립트는 두 패턴을 나란히 재현해서 각각이 실제로 옳은 선택인지, + 그리고 "None을 잘못 썼을 때 실제로 배열이 끝없이 자라는" 버그 자체도 + 수치로 보여줌. + + 실행: `luau 02-none-sentinel-vs-nil-holes.luau` +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) + +-- =============================================================== +-- Part A. 순서가 중요한 배열 — None이 맞는 경우 (PreRef pre-pass, sourceList) +-- =============================================================== + +print("=== A. 순서가 중요한 배열: None으로 소진해야 순서/#t가 안 깨짐 ===") + +local N = 50 + +local function buildList(n) + local t = {} + for i = 1, n do + t[i] = "item" .. i + end + return t +end + +print("-- A-1) BAD: nil로 지우면 순서/#t가 불안정해짐 --") +local bad = buildList(N) +for i = 2, N, 2 do + bad[i] = nil +end +print("bad #t =", #bad, "(Lua 명세상 정의되지 않은 동작 — 실제 값 확인용)") +local badOrder = {} +for i, v in bad do + table.insert(badOrder, tostring(i) .. "=" .. tostring(v)) +end +print("bad 순회 순서(구멍이 생겨 흐트러질 수 있음):", table.concat(badOrder, ", ")) + +print() +print("-- A-2) GOOD: None으로 지우면 #t/순서가 항상 보존됨(PreRef pre-pass에 필요한 성질) --") +local good = buildList(N) +for i = 2, N, 2 do + good[i] = None +end +print("good #t =", #good, "(항상 N — 구멍이 없으니까)") +local goodOrder = {} +for i = 1, #good do + local v = good[i] + goodOrder[#goodOrder + 1] = tostring(i) .. "=" .. (v == None and "None" or tostring(v)) +end +print("good 순회 순서(1..#t로 직접, 항상 안정적):", table.concat(goodOrder, ", ")) + +-- =============================================================== +-- Part B. 순서가 안 중요하고 슬롯 재사용이 필요한 배열 — nil이 맞는 경우 +-- (Ref 콜백/대기자 리스트가 실제로 이 카테고리, 2026-08-09 최종 정정) +-- =============================================================== + +print() +print("=== B. Ref 콜백/대기자 리스트: None을 쓰면 무한 성장 버그, nil+재사용이 맞음 ===") + +-- 등록: table.insert 대신 "빈(nil) 슬롯을 선형 탐색해 재사용" +local function registerNil(list, value) + for i = 1, #list + 1 do + if list[i] == nil then + list[i] = value + return i + end + end +end + +-- 소진: 그 인덱스를 nil로 되돌림(재사용 가능하게) +local function consumeNil(list, i) + list[i] = nil +end + +-- 대조군: 예전에 잘못 썼던 None 기반 버전(table.insert로만 추가, 소진은 None) +local function registerNoneBad(list, value) + table.insert(list, value) + return #list +end +local function consumeNoneBad(list, i) + list[i] = None +end + +print("-- B-1) nil + 슬롯 재사용: 동시 대기자 수만큼만 배열 크기가 유지되는가 --") +do + local waiters = {} + local maxSizeSeen = 0 + -- "등록 -> 곧바로 소진"을 여러 번 반복(:Wait() 호출 후 fire되는 흔한 패턴 흉내) + for cycle = 1, 1000 do + local idx = registerNil(waiters, "waiter" .. cycle) + maxSizeSeen = math.max(maxSizeSeen, #waiters) + consumeNil(waiters, idx) + end + print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(0이어야 함 — 전부 소진됨)") + print("과정 중 관측된 최대 배열 크기 =", maxSizeSeen, "(작게 유지돼야 함, 이상적으론 1)") +end + +print() +print("-- B-2) None + table.insert(예전 버그): 같은 패턴을 반복하면 배열이 끝없이 자람 --") +do + local waiters = {} + for cycle = 1, 1000 do + local idx = registerNoneBad(waiters, "waiter" .. cycle) + consumeNoneBad(waiters, idx) + end + print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(1000이어야 함 — 이게 바로 그 버그)") + local noneCount = 0 + for _, v in waiters do + if v == None then + noneCount += 1 + end + end + print("그 중 None으로 채워진(죽은) 슬롯 개수 =", noneCount, "(전부 죽은 슬롯인데 자리만 차지)") +end + +print() +print("-- B-3) nil 소진이 순서를 안 깨는가(대기자는 순서 안 중요하지만, 그래도 확인) --") +do + local waiters = {} + registerNil(waiters, "keep-me-1") + local idx2 = registerNil(waiters, "temp-2") + registerNil(waiters, "keep-me-3") + consumeNil(waiters, idx2) -- 중간 슬롯 소진 -> 구멍 생김 + local visited = {} + for i, v in waiters do + table.insert(visited, tostring(i) .. "=" .. tostring(v)) + end + print("구멍 있는 상태에서 순회(전부 방문되기만 하면 충분, 순서 무관):", table.concat(visited, ", ")) + -- 이제 새 등록이 빈 슬롯(구멍)을 재사용하는지 확인 + local reusedIdx = registerNil(waiters, "reused") + print("새 등록이 빈 슬롯(index=" .. idx2 .. ")을 재사용했는가?", reusedIdx == idx2) +end + +--[[ + 확인 포인트: + 1. Part A — good(None) 쪽은 #t/순서가 항상 N으로 안정적인가(PreRef + pre-pass가 요구하는 성질 재확인). + 2. Part B-1 — nil+재사용 방식은 반복해도 배열이 안 커지는가(0 또는 + 작은 값 유지)? + 3. Part B-2 — None+table.insert 방식은 실제로 1000까지 자라는가 — + 이게 바로 사용자가 찾아낸 "무한 성장" 버그의 정량적 재현. 이 결과가 + 기대와 다르면(예: 실제로는 안 자란다면) bind-system-plan.md의 정정 + 근거 자체를 재검토해야 하니 반드시 알려줄 것. + 4. Part B-3 — 새 등록이 소진된 빈 슬롯(index=idx2)을 실제로 재사용하는가 + — 이게 "table.insert 대신 선형 탐색 재사용 등록 함수"가 실제로 + 의도대로 동작하는지의 핵심 확인. +]] diff --git a/.claude/luau-test/03-recursive-store-bind-dispatch.luau b/.claude/luau-test/03-recursive-store-bind-dispatch.luau new file mode 100644 index 0000000..62b1115 --- /dev/null +++ b/.claude/luau-test/03-recursive-store-bind-dispatch.luau @@ -0,0 +1,168 @@ +--[[ + 검증 대상: process(inst,k,v)/retract(inst,k,v) 기반 재귀 재-dispatch + 모델(.claude/base/bind-system-plan.md "확정된 디스패치 모델" 절)이 실제 + Luau 함수 재귀로 자연스럽게 짜이는지, 우선순위 스캔(isHandlable)이 + 기대대로 동작하는지에 대한 최소 스파이크. + + 배경: ROADMAP.md M0 3번째 항목 "process/retract 재귀 재-process + 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable + 우선순위 스캔 포함)". + + 여기서는 다단 체인(retractUnder)까지는 다루지 않음 — 그건 + 04-dispatch-chain-retractUnder.luau가 별도로 다룸(단일 owner 슬롯 + 추적이 왜 깨지는지까지 포함). 이 파일은 "재귀 자체가 도는가", "우선순위 + 스캔이 맞는 핸들러를 고르는가", "None -> nil 재디스패치가 다음 + 핸들러로 자연히 좁혀지는가"까지만 검증. + + 실행: `luau 03-recursive-store-bind-dispatch.luau` + + 참고(2026-08-09 세션 갱신 반영): 아래 makeStore의 `subscribe(fn)`은 + 이 스파이크 전용으로 단순화한 것 — 실제 base 설계는 StoreBind가 + `state:Observer(fn)` + `bindLifetime(inst, observer)`/ + `unbindLifetime(inst, observer)`(`.claude/base/lifecycle-pattern.md`) + 조합으로 구독/해제한다. 여기서 검증하려는 건 그 구독 배관이 아니라 + "우선순위 스캔+재귀 process/retract 자체가 Luau에서 잘 도는가"라서 + 영향 없음 — 실제 Handler 구현 짤 때는 subscribe 대신 저 조합을 쓸 것. +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) + +-- 아주 단순화된 "Store" 시늉 — 실제로는 Source/State가 되겠지만 여기선 +-- 그냥 값+구독자 리스트를 가진 테이블 +local StoreTag = {} +local function isStoreLike(v) + return type(v) == "table" and v[StoreTag] == true +end +local function makeStore(initial) + local self = { [StoreTag] = true, value = initial, subscribers = {} } + function self.get(_self) + return self.value + end + function self.set(_self, v) + self.value = v + for _, fn in self.subscribers do + fn(v) + end + end + function self.subscribe(_self, fn) + table.insert(self.subscribers, fn) + end + return self +end + +-- Dispatch 최소 스파이크 +local Dispatch = {} +local handlers = {} + +function Dispatch.addHandler(handler) + table.insert(handlers, handler) + table.sort(handlers, function(a, b) + return a.priority > b.priority + end) +end + +function Dispatch.getHandler(inst, k, v) + for _, h in handlers do + if h.isHandlable(inst, k, v) then + return h + end + end + return nil +end + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if h then + print(string.format(" [Dispatch.process] inst=%s k=%s -> handler=%s", tostring(inst), tostring(k), h.name)) + h.process(inst, k, v) + else + print(string.format(" [Dispatch.process] inst=%s k=%s -> 매치되는 핸들러 없음!", tostring(inst), tostring(k))) + end +end + +-- 핸들러 1: NoneHandler (해시 파트 전용, 매우 높은 우선순위) +Dispatch.addHandler({ + name = "NoneHandler", + priority = 1000, + isHandlable = function(inst, k, v) + return v == None + end, + process = function(inst, k, v) + Dispatch.process(inst, k, nil) -- 재귀 재호출 + end, + retract = function() end, +}) + +-- 핸들러 2: StoreBind (store-like 값을 잡아 재귀 재-dispatch) +Dispatch.addHandler({ + name = "StoreBind", + priority = 900, + isHandlable = function(inst, k, v) + return isStoreLike(v) + end, + process = function(inst, k, store) + local function reprocess(realv) + print( + string.format( + " [StoreBind] %s.%s 값 변경 감지 -> 재귀 process(realv=%s)", + tostring(inst), + tostring(k), + tostring(realv) + ) + ) + Dispatch.process(inst, k, realv) + end + store:subscribe(reprocess) + reprocess(store:get()) -- 최초 1회 적용 (state:Observer의 "등록 즉시 1회 실행"을 흉내) + end, + retract = function(inst, k, v) + print(string.format(" [StoreBind.retract] %s.%s 구독 해제(흉내)", tostring(inst), tostring(k))) + end, +}) + +-- 핸들러 3: PropertyHandler (catch-all, 가장 낮은 우선순위) +Dispatch.addHandler({ + name = "PropertyHandler", + priority = 0, + isHandlable = function() + return true + end, + process = function(inst, k, v) + print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v))) + end, + retract = function() end, +}) + +print("=== 1. Store 값을 프로퍼티에 바인드 ===") +local colorStore = makeStore("red") +Dispatch.process("Frame1", "BackgroundColor", colorStore) + +print() +print("=== 2. Store 값 변경 -> 재귀 재-dispatch로 실제 값이 다시 세팅되는가 ===") +colorStore:set("blue") + +print() +print("=== 3. None 센티널 -> nil로 재귀 재-dispatch되어 PropertyHandler로 흘러가는가 ===") +Dispatch.process("Frame1", "Rotation", None) + +print() +print("=== 4. 무한 재귀 없이 종료되는가 ===") +print("위 1~3에서 스택 오버플로/무한 루프 없이 정상 종료됐다면 통과") + +--[[ + 확인 포인트: + 1. 콘솔에 handler=StoreBind가 먼저 찍히고, 그 다음 재귀로 + handler=PropertyHandler가 찍히는가? + 2. colorStore:set("blue") 이후 PropertyHandler가 다시(blue로) 불리는가? + 3. None 케이스가 PropertyHandler까지 자연스럽게 흘러가는가(중간에 + NoneHandler가 한 번만 관여하고 끝나는가)? + 4. table.sort 기반 우선순위 스캔이 매번 안정적으로 같은 순서를 내는가 + (Luau table.sort는 unstable sort일 수 있음 — 동일 priority 핸들러가 + 여러 개면 순서가 실행마다 바뀔 수 있다는 점 주의. 실제 구현에서는 + priority를 세밀하게 나누거나 등록 순서를 tie-breaker로 쓰는 걸 + 검토할 가치가 있어 보임 — 지금 base 문서엔 이 tie-break 규칙이 + 명시돼 있지 않음, 실제로 문제가 되면 base/bind-system-plan.md에 + 추가할 것). +]] diff --git a/.claude/luau-test/04-dispatch-chain-retractUnder.luau b/.claude/luau-test/04-dispatch-chain-retractUnder.luau new file mode 100644 index 0000000..9bf5128 --- /dev/null +++ b/.claude/luau-test/04-dispatch-chain-retractUnder.luau @@ -0,0 +1,201 @@ +--[[ + 검증 대상: Dispatch가 (inst,k)별 핸들러 체인을 배열로 소유하고, + retractUnder(inst,k,keep,v)가 꼬리부터 keep 앞까지 정리하는 설계 + (.claude/base/bind-system-plan.md "Dispatch 체인" 절)가 다단 체인 + (A->B->C)에서 실제로 정확한지 검증. + + 배경: 2026-08-08 세 번째 세션 — "전역 소유자 슬롯 하나"로 추적하는 + 1차 설계가 재귀/래핑 핸들러(A가 B로 위임하는데 A 자신도 나중에 + 재계산되는 경우)에서 깨지는 걸 반례로 확인하고 체인 방식으로 교체함. + CLAUDE.md는 "M2/M4 스파이크 검증 목록에 chains/retractUnder가 다단 + 체인에서 실제로 정확히 동작하는지가 새로 추가됨(추론만으로 확정된 것)" + 이라고 명시 — 아직 실제 Luau로 돌려본 적 없음. 이 파일이 그 검증. + + 시나리오: StoreA(바깥 store) -> StoreBind가 잡아서 그 값을 다시 + Dispatch.process로 재귀 -> 그 값이 또 다른 Store(StoreB, "이중 store" + 케이스를 흉내)일 때 두 번째 StoreBind가 또 잡아서 재귀 -> 최종적으로 + PropertyHandler가 실제 세팅. 즉 A(StoreBind)->B(StoreBind again)->C(Property) + 3단 체인. ("Store가 Store를 담지 않는다"가 설계상 확정이라 이 자체는 + UB에 가까운 입력이지만, 체인 메커니즘이 다단에서 실제로 버티는지는 + 그것과 별개로 확인해둘 가치가 있어 일부러 스트레스 테스트로 씀.) + + 실행: `luau 04-dispatch-chain-retractUnder.luau` + + 참고(2026-08-09 세션 갱신 반영): 03번과 동일하게 아래 `subscribe(fn)`은 + 이 스파이크 전용 단순화 — 실제로는 `state:Observer(fn)` + + `bindLifetime`/`unbindLifetime` 조합(`.claude/base/lifecycle-pattern.md`)을 + 쓴다. `retract`가 할 일이 "구독 해제"라는 본질은 같아서 체인/ + retractUnder 로직 검증엔 영향 없음. +]] + +local StoreTag = {} +local function isStoreLike(v) + return type(v) == "table" and v[StoreTag] == true +end +local function makeStore(initial) + local self = { [StoreTag] = true, value = initial, subscribers = {} } + function self.get(_self) + return self.value + end + function self.set(_self, v) + self.value = v + for _, fn in self.subscribers do + fn(v) + end + end + function self.subscribe(_self, fn) + table.insert(self.subscribers, fn) + end + return self +end + +-- Relate 대용 (weak-key까지는 이 스파이크에서 안 다룸, 순수 로직 검증이 목적 — +-- weak-key/GC 쪽은 07-relate-weak-table-gc.luau가 따로 다룸) +local chains = {} -- [inst] = { [k] = { handler, handler, ... } } +local function chainFor(inst, k) + chains[inst] = chains[inst] or {} + chains[inst][k] = chains[inst][k] or {} + return chains[inst][k] +end + +local Dispatch = {} +local handlers = {} + +function Dispatch.addHandler(h) + table.insert(handlers, h) + table.sort(handlers, function(a, b) + return a.priority > b.priority + end) +end + +function Dispatch.getHandler(inst, k, v) + for _, h in handlers do + if h.isHandlable(inst, k, v) then + return h + end + end + return nil +end + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if not h then + print(string.format(" (매치 없음: %s.%s = %s)", tostring(inst), tostring(k), tostring(v))) + return + end + local list = chainFor(inst, k) + table.insert(list, h) + print(string.format(" [chain push] %s.%s <- %s (체인 길이=%d)", tostring(inst), tostring(k), h.name, #list)) + h.process(inst, k, v) +end + +-- .claude/base/bind-system-plan.md의 pseudo code 그대로 옮김 +function Dispatch.retractUnder(inst, k, keep, v) + local list = chainFor(inst, k) + local cutoff = 0 + if keep then + for i, h in list do + if h == keep then + cutoff = i + break + end + end + end + for i = #list, cutoff + 1, -1 do + local retractedHandler = list[i] + local passedValue = (i == cutoff + 1) and v or nil + print( + string.format( + " [retractUnder] %s.%s: %s.retract(v=%s) 호출, 체인에서 제거", + tostring(inst), + tostring(k), + retractedHandler.name, + tostring(passedValue) + ) + ) + retractedHandler.retract(inst, k, passedValue) + list[i] = nil + end +end + +-- 핸들러: StoreBind (self 식별을 위해 핸들러 테이블 자기 자신을 process 안에서 캡처) +local function makeStoreBindHandler(name, priority) + local self + self = { + name = name, + priority = priority, + isHandlable = function(inst, k, v) + return isStoreLike(v) + end, + process = function(inst, k, store) + local function reprocess(realv) + print( + string.format( + " [%s] %s.%s 재계산 -> retractUnder(keep=self) 먼저, 그 다음 재귀 process", + name, + tostring(inst), + tostring(k) + ) + ) + Dispatch.retractUnder(inst, k, self, realv) + Dispatch.process(inst, k, realv) + end + store:subscribe(reprocess) + reprocess(store:get()) + end, + retract = function(inst, k, v) + print(string.format(" [%s.retract] 나 자신(구독) 정리", name)) + end, + } + return self +end + +Dispatch.addHandler(makeStoreBindHandler("StoreBindA", 900)) +Dispatch.addHandler({ + name = "PropertyHandler", + priority = 0, + isHandlable = function() + return true + end, + process = function(inst, k, v) + print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v))) + end, + retract = function(inst, k, v) + print(string.format(" [PropertyHandler.retract] 이전 값 무름")) + end, +}) + +print('=== 1단계: StoreA(값="hello") 바인드 ===') +local storeA = makeStore("hello") +Dispatch.process("Frame1", "Text", storeA) +print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) + +print() +print("=== 2단계: StoreA 값을 다른 일반 값으로 바꿈(체인이 A 밑을 정확히 정리하는가) ===") +storeA:set("world") +print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개여야 정상)") + +print() +print("=== 3단계: StoreA 값을 store로 다시 바꿔서(다단 체인 유도) 스트레스 테스트 ===") +local storeB = makeStore("nested") +storeA:set(storeB) +print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) + +print() +print("=== 4단계: 안쪽 StoreB 값을 바꿔서, retractUnder(keep=StoreBindA 자신)가") +print(" 바깥 A는 안 건드리고 그 밑(B 이후)만 정리하는지 확인 ===") +storeB:set("nested-changed") + +--[[ + 확인 포인트 (이게 이 파일의 핵심 목적): + 1. 2단계에서 storeA:set("world") 이후 체인 길이가 정확히 2(A, Property)로 + 돌아오는가 — retractUnder가 이전 PropertyHandler를 정리하고 새로 + push했는가, 아니면 계속 누적돼서 체인이 무한정 길어지는가? + (누적되면 버그 — 체인이 GC 안 되는 메모리 누수이자 논리 오류) + 2. 3단계~4단계에서 A(StoreBindA) 자신은 살아남고, 그 밑(구 PropertyHandler + 또는 구 중첩 핸들러)만 정리되는가 — "A가 자길 엉뚱하게 retract하는" + 버그(CLAUDE.md가 기각한 1차 설계의 실패 모드)가 재현되지 않는가? + 3. 체인 길이가 각 단계마다 예상한 값과 정확히 일치하는가(주석에 적어둔 + 기대값과 실제 print 결과를 비교). + 4. 스택 오버플로 없이 전부 정상 종료되는가. +]] diff --git a/.claude/luau-test/05-store-state-diamond-propagation.luau b/.claude/luau-test/05-store-state-diamond-propagation.luau new file mode 100644 index 0000000..b0e0870 --- /dev/null +++ b/.claude/luau-test/05-store-state-diamond-propagation.luau @@ -0,0 +1,157 @@ +--[[ + 검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get() + 시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지. + + 배경: ROADMAP.md M0 1번째 항목 "Store/State push-invalidate -> + pull-recompute propagation을 실제로 짜보기(다이아몬드 의존성 케이스 + 포함 — 이미 invalid면 전파 중단되는지)". + + 다이아몬드 구조: + source + / \ + stateA stateB + \ / + stateC (:With(stateA, stateB):Compute(...)) + + 검증할 것 두 가지: + 1. source가 바뀌면 invalidate 신호가 stateA/stateB를 거쳐 stateC까지 + 전파되는데, "이미 dirty로 표시된 노드는 더 이상 아래로 전파하지 + 않는다"는 방어가 있어야 다이아몬드에서 stateC가 두 경로로 두 번 + invalidate 신호를 받아도 문제없이 처리됨(도달 자체는 두 번 일어나되, + 두 번째는 즉시 조기 종료돼야 함). + 2. stateC:Get()을 실제로 호출했을 때, compute 함수가 정확히 1번만 + 실행되는가(다이아몬드 때문에 stateA 경로/stateB 경로 각각 한 번씩 + 총 2번 이상 실행되면 버그). + + 실행: `luau 05-store-state-diamond-propagation.luau` +]] + +local function makeSource(initial) + local self = { value = initial, listeners = {} } + function self:Get() + return self.value + end + function self:Set(v) + self.value = v + self:Invalidate() + end + function self:Invalidate() + -- source 자신은 dirty 개념이 없음(항상 최신) — 그냥 리스너에게 신호만 쏨 + for _, fn in self.listeners do + fn() + end + end + function self:OnInvalidate(fn) + table.insert(self.listeners, fn) + end + return self +end + +local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 } +local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 } + +local function makeState(name, deps, computeFn) + local self = { + name = name, + dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty + cached = nil, + listeners = {}, + } + function self:Invalidate() + invalidateCallCount[name] += 1 + if self.dirty then + -- 핵심: 이미 dirty면 더 아래로 전파하지 않음(다이아몬드 방어) + print(string.format(" [%s] 이미 dirty -> 전파 중단", name)) + return + end + print(string.format(" [%s] dirty로 표시, 아래로 전파", name)) + self.dirty = true + for _, fn in self.listeners do + fn() + end + end + function self:OnInvalidate(fn) + table.insert(self.listeners, fn) + end + function self:Get() + if self.dirty then + computeCallCount[name] += 1 + print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name])) + local args = {} + for i, d in deps do + args[i] = d:Get() + end + self.cached = computeFn(table.unpack(args)) + self.dirty = false + else + print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name)) + end + return self.cached + end + for _, d in deps do + d:OnInvalidate(function() + self:Invalidate() + end) + end + return self +end + +local source = makeSource(1) +local stateA = makeState("stateA", { source }, function(v) + return v + 10 +end) +local stateB = makeState("stateB", { source }, function(v) + return v + 100 +end) +local stateC = makeState("stateC", { stateA, stateB }, function(a, b) + return a + b +end) + +print("=== 1. 최초 Get() — 전부 계산돼야 함 ===") +print("stateC:Get() =", stateC:Get()) +print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC) +assert( + computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1, + "최초 계산 횟수가 예상과 다름" +) + +print() +print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===") +print("stateC:Get() =", stateC:Get()) +assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)") + +print() +print("=== 3. source:Set() -> 다이아몬드 invalidate 전파 ===") +source:Set(2) +print( + "invalidate 호출 횟수(stateC):", + invalidateCallCount.stateC, + "(stateA 경로 1번 + stateB 경로 1번 = 2번 호출은 정상, 단 2번째는 즉시 'already dirty'로 중단돼야 함)" +) + +print() +print("=== 4. invalidate 이후 Get() — 정확히 1번만 재계산되는가 ===") +print("stateC:Get() =", stateC:Get()) +print( + "compute 호출 횟수(stateC):", + computeCallCount.stateC, + "(2여야 함 — 1차 계산 + 이번 재계산, 3 이상이면 다이아몬드 중복 재계산 버그)" +) +assert(computeCallCount.stateC == 2, "다이아몬드 의존성 때문에 stateC가 여러 번 재계산됨 (버그)") + +print() +print("모든 assert 통과 — 다이아몬드 전파/재계산 모델이 예상대로 동작함") + +--[[ + 확인 포인트: + 1. 위 assert들이 전부 통과하는가(하나라도 실패하면 error로 죽고 스택 + 트레이스가 찍힘 — 그대로 알려줄 것). + 2. invalidateCallCount.stateC가 정확히 2(stateA 경로, stateB 경로 각각 + 1번씩 도달)이지만, 그 중 두 번째 호출은 "이미 dirty" 로그로 조기 + 종료되는지 눈으로 확인. + 3. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만 + 흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는 + 부분(.claude/base/bind-system-plan.md "Store/State/Source 온톨로지" + 절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의 + 정확성"만 검증 대상. +]] diff --git a/.claude/luau-test/06-component-boundary-nil-hole-props.luau b/.claude/luau-test/06-component-boundary-nil-hole-props.luau new file mode 100644 index 0000000..fc6b2f5 --- /dev/null +++ b/.claude/luau-test/06-component-boundary-nil-hole-props.luau @@ -0,0 +1,87 @@ +--!strict +--[[ + 검증 대상: 컴포넌트 경계에서 props.Modifier/props.Ref를 "or None"으로 + 감싸 넘기는 필수 관용구가 실제로 nil-hole 문제를 막아주는지, 그리고 + `export type Params = {...}`로 타입 체크되는 컴포넌트 하나가 실제 + Luau에서 문제없이 짜이는지. + + 배경: ROADMAP.md M0 5번째 항목, .claude/base/component-composition-plan.md + "필수 관용구" 절, .claude/research/pre-implementation-audit.md 1-5. + + 두 가지 방법으로 확인 필요함: + 1. 런타임 동작(nil-hole 재현) 확인: `luau 06-component-boundary-nil-hole-props.luau` + 2. 타입 체크(Params 타입, Modifier/Ref 타입 흉내) 확인: + `luau-analyze 06-component-boundary-nil-hole-props.luau` + (luau-analyze가 로컬에 없으면 Luau 공식 릴리즈 CLI 툴체인 필요 — + https://github.com/luau-lang/luau/releases, 또는 lune/rojo 배포판) +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) :: any + +-- Modifier/Ref를 아주 얇게 흉내낸 타입(실제 구현 API 모양과 다를 수 있음, +-- 여기선 오직 "props.Modifier or None" 패턴의 타입/런타임 동작만 검증) +type FakeModifier = { isModifier: true } +type FakeRef = { isRef: true } + +export type Params = { + Modifier: FakeModifier?, + Ref: FakeRef?, + Text: string, +} + +local function MyComponent(props: Params) + -- 핵심 관용구 — 이게 없으면 아래 "BAD" 케이스처럼 nil-hole이 생김 + local children = { + props.Modifier or None, + props.Ref or None, + props.Text, + "fixed-child-1", + "fixed-child-2", + } + return children +end + +print("=== BAD: or None 없이 raw로 꽂았을 때 ===") +local badProps: Params = { Text = "hello" } -- Modifier/Ref 둘 다 안 넘김 +local badChildren = { badProps.Modifier, badProps.Ref, badProps.Text, "fixed-child-1", "fixed-child-2" } +print("bad #t =", #badChildren, "(정의된 대로면 5, 하지만 앞쪽 nil-hole 때문에 불안정할 수 있음)") +for i, v in badChildren do + print(" bad[" .. tostring(i) .. "] =", tostring(v)) +end + +print() +print("=== GOOD: or None 관용구 사용 ===") +local goodChildren = MyComponent(badProps) +print("good #t =", #goodChildren, "(항상 5여야 함)") +for i = 1, #goodChildren do + print(" good[" .. tostring(i) .. "] =", tostring(goodChildren[i])) +end + +print() +print("=== 대조군: Modifier/Ref 둘 다 넘겼을 때도 동일하게 동작하는가 ===") +local fullProps: Params = { + Modifier = { isModifier = true }, + Ref = { isRef = true }, + Text = "hello", +} +local fullChildren = MyComponent(fullProps) +print("full #t =", #fullChildren, "(항상 5)") + +--[[ + 확인 포인트 (런타임 실행): + 1. bad #t가 5가 아니거나(예: 3), 순회 시 앞쪽 두 슬롯이 이상하게 뒤로 + 밀리거나 사라지는가 — 이게 실제 nil-hole 버그의 재현. + 2. good/full 양쪽 모두 #t가 정확히 5이고, 순서(Modifier자리, Ref자리, + Text, fixed-child-1, fixed-child-2)가 항상 지켜지는가. + + 확인 포인트 (luau-analyze 타입 체크): + 1. `export type Params`가 옵셔널 Modifier?/Ref? 필드로 문제없이 + 타입체크되는가. + 2. `props.Modifier or None`에서 None을 `any`로 캐스팅해뒀는데, 이걸 + 실제 Modifier/None 유니온 타입으로 더 정확히 표현하려면 어떤 타입 + 선언이 필요한지(예: `type Slot = T | typeof(None)`류) 실 Luau + 에러 메시지를 보고 판단해볼 것 — 지금 파일은 `any` 캐스팅으로 + 일단 회피해뒀음, 이 부분은 M7/M8 실제 구현 시 정확한 타입을 찾아야 함. +]] diff --git a/.claude/luau-test/07-relate-weak-table-gc.luau b/.claude/luau-test/07-relate-weak-table-gc.luau new file mode 100644 index 0000000..3e7e39e --- /dev/null +++ b/.claude/luau-test/07-relate-weak-table-gc.luau @@ -0,0 +1,135 @@ +--[[ + 검증 대상: .claude/base/relate-plan.md가 확정한 Relate의 실제 구조 + ({ [inst(weak)]: { StrongMap?, WeakMap? } })가 Luau의 진짜 weak-table + GC 동작과 맞아떨어지는지 — lazy 서브테이블 생성, WeakMap 공유 + 메타테이블, 그리고 무엇보다 "inst가 죽으면 중첩된 것까지 전부 + 같이 GC되는가"라는 .claude/base/bind-system-plan.md "왜 GC-안전한가" + 절의 핵심 주장 자체. + + 배경: .claude/base/relate-plan.md "M2 착수 시 실측 확인" 캐비엇. + + 중요한 제약: Roblox의 실제 게임 스크립트 환경에는 collectgarbage()가 + 노출되지 않음(강제 GC 트리거 불가) — 그래서 이 GC 타이밍 검증은 + Roblox Studio가 아니라 반드시 순수 luau CLI에서 해야 함(standalone + Luau 인터프리터는 collectgarbage를 허용). Roblox 쪽은 VM/GC 구현 + 자체가 같은 Luau이므로 여기서 확인된 동작이 그대로 적용된다고 가정할 + 수 있지만, "그대로 적용된다"는 가정 자체는 이 스크립트로 검증 불가능한 + 항목으로 남음(참고만 할 것). + + 실행: `luau 07-relate-weak-table-gc.luau` +]] + +local sharedWeakValueMeta = { __mode = "v" } + +local function Relate() + local outer = setmetatable({}, { __mode = "k" }) -- inst는 항상 weak + local relate = {} + + local function subtable(inst) + local t = outer[inst] + if not t then + t = {} + outer[inst] = t + end + return t + end + + function relate.SetStrong(_, inst, key, value) + local t = subtable(inst) + t.StrongMap = t.StrongMap or {} + t.StrongMap[key] = value + end + function relate.GetStrong(_, inst, key) + local t = outer[inst] + if not t or not t.StrongMap then + return nil + end + return t.StrongMap[key] + end + function relate.SetWeak(_, inst, key, value) + local t = subtable(inst) + if not t.WeakMap then + t.WeakMap = setmetatable({}, sharedWeakValueMeta) + end + t.WeakMap[key] = value + end + function relate.GetWeak(_, inst, key) + local t = outer[inst] + if not t or not t.WeakMap then + return nil + end + return t.WeakMap[key] + end + + -- 디버깅 전용 — 실제 Relate API엔 없음, 이 스파이크에서 관찰용으로만 + function relate._debugHasSubtable(_, inst) + return outer[inst] ~= nil + end + + return relate +end + +print("=== 1. lazy 생성 확인 ===") +local relate1 = Relate() +local instA = {} -- 실제로는 Roblox Instance지만, 순수 luau CLI엔 없으므로 plain table로 대체 +print("Set 호출 전 subtable 존재?", relate1:_debugHasSubtable(instA), "(false여야 함)") +relate1:SetStrong(instA, "k1", "v1") +print("SetStrong 호출 후 subtable 존재?", relate1:_debugHasSubtable(instA), "(true여야 함)") +print("GetStrong(instA, k1) =", relate1:GetStrong(instA, "k1")) +print("GetWeak(instA, 아무거나) — WeakMap 아직 안 만들어졌어도 nil로 안전하게 반환?", relate1:GetWeak(instA, "nope")) + +print() +print("=== 2. inst가 스코프를 벗어나면 그 안의 StrongMap도 같이 사라지는가(간접 확인) ===") +local relate2 = Relate() +do + local instB = {} + relate2:SetStrong(instB, "tween", "FAKE_TWEEN_INSTANCE") + print("instB 살아있을 때 GetStrong =", relate2:GetStrong(instB, "tween")) + -- instB에 대한 유일한 강참조는 이 do-블록의 로컬 변수뿐 — 블록을 벗어나면 사라짐 +end +collectgarbage() -- 표준 luau CLI에서 지원(Roblox에선 사용 불가 — 위 주석 참고) +collectgarbage() +print("(instB 참조를 잃었으므로 같은 값으로 재조회는 애초에 불가능 — 아래 3번이 실질 확인)") + +print() +print("=== 3. weak key가 실제로 GC되는지 카운팅으로 확인 ===") +local relate3 = Relate() +local keepAlive = {} -- 이 배열에 담긴 것만 살아남음 +for i = 1, 100 do + local inst = {} + relate3:SetStrong(inst, "data", "payload" .. i) + if i <= 10 then + keepAlive[i] = inst -- 앞 10개만 강하게 붙잡아둠 + end + -- 나머지 90개는 루프 변수 스코프를 벗어나는 즉시 참조를 잃음 +end +collectgarbage() +collectgarbage() + +local aliveCount = 0 +for i = 1, 10 do + if relate3:GetStrong(keepAlive[i], "data") ~= nil then + aliveCount += 1 + end +end +print("강하게 붙잡아둔 10개 중 살아있는 것:", aliveCount, "(10이어야 함)") + +print() +print('=== 참고: collectgarbage("count") 메모리 변화(대략적 신호일 뿐) ===') +print(collectgarbage("count"), "KB") + +--[[ + 확인 포인트: + 1. 1번 섹션 — SetStrong 호출 전엔 subtable이 안 만들어져 있다가, 호출 + 순간에만 생기는가(lazy 생성 실측). + 2. 3번 섹션 — collectgarbage()가 실제로 동작하고(에러 안 나고), + 강하게 붙잡아둔 10개는 살아있는가(당연히 그래야 함 — sanity check). + 3. **가장 중요한 미해결 관찰**: 이 스크립트는 "죽은 90개가 실제로 + GC됐는지"를 직접 카운트하지 못함(Luau가 weak table 내부 엔트리 + 개수를 세는 표준 API를 안 줌) — `collectgarbage("count")`로 전체 + 메모리 사용량 변화를 보는 정도가 간접 확인의 최선. 필요하면 위 + 3번 섹션의 루프를 더 크게(예: 100 -> 1,000,000) 돌리면서 루프 + 전후 collectgarbage("count") 차이를 비교해보면 신호가 더 뚜렷해질 + 수 있음(주의: GC는 정확한 타이밍을 보장 안 하므로 완벽한 증거는 + 아님, 참고 신호 정도로만 볼 것). +]] diff --git a/.claude/luau-test/08-type-source-satisfies-state.luau b/.claude/luau-test/08-type-source-satisfies-state.luau new file mode 100644 index 0000000..290e8fc --- /dev/null +++ b/.claude/luau-test/08-type-source-satisfies-state.luau @@ -0,0 +1,78 @@ +--!strict +--[[ + 검증 대상: Source가 구조적으로 State를 만족하는(self 타이핑 + + State 참조가 섞인 제네릭 :Compute) 설계가 Luau 타입 솔버에서 안전하게 + 추론되는지 — 실제 실행이 아니라 타입 체크(luau-analyze) 대상. + + 배경: .claude/base/store-semantics.md "검증 필요(확정 아님, M0 스파이크 + 대상)" 절, ROADMAP.md M0 2번째 항목. + + 핵심 우려: State가 거꾸로 Source를 참조하는 "상호 재귀"는 Luau + 솔버가 취약한 패턴 — 그래서 아래 State는 Source를 전혀 참조하지 + 않도록 독립적으로 먼저 정의하고, Source만 State를 단방향으로 + 참조하게 구성함. 타입은 사용자 선호대로 &(교차)가 아니라 손으로 + 펼쳐 씀(런타임 구현 델리게이션과는 별개 축이라 상관없음). + + 실행: `luau-analyze 08-type-source-satisfies-state.luau` + (로컬에 luau-analyze가 없으면 Luau 공식 릴리즈의 CLI 툴체인 설치 필요 — + https://github.com/luau-lang/luau/releases, 또는 lune 배포판에 포함된 것) + + 기대 결과: 에러 없이 통과하거나, 통과 안 하면 정확히 *어느 줄에서* + *무슨 에러*가 나는지가 다음 결정에 중요한 정보임 — 에러가 나면 그 + 메시지를 그대로 가져와서 알려줄 것. +]] + +-- State는 Source를 절대 참조하지 않음(단방향 의존을 위한 핵심 제약) +export type State = { + Get: (self: State) -> T, + With: (self: State, ...State) -> State, + Compute: (self: State, fn: (T) -> U) -> State, +} + +-- Source만 State를 참조(단방향) — self 타이핑(Source 자신을 가리킴)과 +-- 바깥 타입 참조(State)가 섞인 제네릭 메소드가 바로 검증 대상 +export type Source = { + Get: (self: Source) -> T, + With: (self: Source, ...State) -> State, + Compute: (self: Source, fn: (T) -> U) -> State, + Set: (self: Source, value: T) -> (), + Emit: (self: Source) -> (), +} + +-- 1. Source 값을 만드는 흉내 생성자(런타임 구현은 아직 없으므로 타입만 맞추는 더미) +local function fakeSource(default: T): Source + return (nil :: any) :: Source +end + +-- 2. State를 요구하는 함수에 Source를 그대로 넘길 수 있는가 +-- (구조적 서브타이핑 — "Source가 State를 만족함" 절의 핵심 주장) +local function useAsState(s: State): T + return s:Get() +end + +local mySource: Source = fakeSource(0) +local viaSubtype: number = useAsState(mySource) -- 여기가 타입체크 되는지가 핵심 + +-- 3. Compute 체이닝이 제네릭을 타고 잘 흐르는가(Source -> State -> State) +local derived1: State = mySource:Compute(function(n: number): string + return tostring(n) +end) +local derived2: State = derived1:Compute(function(s: string): boolean + return #s > 0 +end) + +-- 4. store.key가 Source를 직접 반환한다는 모델(레코드 필드 읽기/쓰기 대칭) +export type Store = { + -- 실제로는 defaults의 각 키를 Source<...>로 매핑하는 mapped type이 이상적이지만 + -- Luau에 mapped type이 없으므로(2026-08 시점) 구체 예시 하나로만 검증 + Health: Source, +} + +local function useStore(store: Store) + store.Health:Set(100) -- 쓰기 + local hp: number = store.Health:Get() -- 읽기 — 같은 필드 타입(Source)으로 대칭 + return hp +end + +print("이 파일은 luau-analyze로만 의미가 있음 (런타임 실행은 그냥 통과함)") +print(viaSubtype, derived2) diff --git a/.claude/luau-test/09-type-modifier-overridden-subtype.luau b/.claude/luau-test/09-type-modifier-overridden-subtype.luau new file mode 100644 index 0000000..69be83c --- /dev/null +++ b/.claude/luau-test/09-type-modifier-overridden-subtype.luau @@ -0,0 +1,73 @@ +--!strict +--[[ + 검증 대상: Modifier.Overridden(mod1, mod2, ...)가 서브타입 관계인 + 서로 다른 Modifier 타입(FrameModifier <: GuiObjectModifier)을 섞을 때 + 타입이 통과하는지 — 필드 setter가 전부 self를 반환하는 fluent 타입이라 + 구조적 서브타이핑이 실제로 성립하는지가 관건. + + 배경: .claude/base/modifier-plan.md 9-2번 절, ROADMAP.md M7. + "막히는 지점"으로 문서가 지목한 것: `:BackgroundColor3` 같은 메소드가 + FrameModifier에서는 FrameModifier를, GuiObjectModifier에서는 + GuiObjectModifier를 리턴하므로 같은 이름 필드의 리턴 타입이 갈려서 + 단순 구조적 서브타이핑이 깨질 수 있음. + + 이 파일은 두 버전을 나란히 둠: + A) "정직한" 버전 — 메소드 리턴 타입이 각자 자기 자신 + B) fallback 버전 — 문제가 생기면 쓸 `Overridden(...: any): any` 완화형 + luau-analyze를 돌려서 A가 실제로 어디서 막히는지(또는 안 막히는지) + 확인하는 게 목적. + + 실행: `luau-analyze 09-type-modifier-overridden-subtype.luau` +]] + +-- ===== A) 정직한 버전 ===== + +export type GuiObjectModifier = { + -- Color3 대신 number로 단순화(luau-analyze 단독 실행 환경엔 Roblox 타입이 없을 수 있어서) + BackgroundColor3: (self: GuiObjectModifier, v: number) -> GuiObjectModifier, + Apply: (self: GuiObjectModifier, f: (GuiObjectModifier) -> GuiObjectModifier) -> GuiObjectModifier, +} + +export type FrameModifier = { + BackgroundColor3: (self: FrameModifier, v: number) -> FrameModifier, + Apply: (self: FrameModifier, f: (FrameModifier) -> FrameModifier) -> FrameModifier, + ClipsDescendants: (self: FrameModifier, v: boolean) -> FrameModifier, -- Frame 전용 필드 +} + +local function fakeFrameModifier(): FrameModifier + return (nil :: any) :: FrameModifier +end + +-- 시도 1: FrameModifier 값을 GuiObjectModifier 변수에 그대로 대입 — 통과하는가? +local frameMod: FrameModifier = fakeFrameModifier() +local asGuiObjectMod: GuiObjectModifier = frameMod -- <- 여기가 luau-analyze 에러 나는지 확인 포인트 1 + +-- 시도 2: Overridden을 GuiObjectModifier 시그니처로 선언하고 FrameModifier를 인자로 넘김 +local function OverriddenHonest(...: GuiObjectModifier): GuiObjectModifier + return (nil :: any) :: GuiObjectModifier +end +local result1 = OverriddenHonest(frameMod) -- <- 확인 포인트 2 + +-- ===== B) fallback(any) 버전 ===== + +local function OverriddenLoose(...: any): any + return (nil :: any) +end +local result2 = OverriddenLoose(frameMod, asGuiObjectMod) -- 이건 항상 통과해야 함(any이므로) + +print("런타임 실행 자체는 의미 없음 — luau-analyze 출력을 확인할 것") +print(result1, result2) + +--[[ + 확인 포인트: + 1. "시도 1"(asGuiObjectMod 대입)에서 luau-analyze가 에러를 내는가? + 낸다면 정확한 에러 메시지(타입 불일치 상세)를 기록해둘 것 — + BackgroundColor3 필드의 리턴 타입 불일치 때문인지, 아니면 다른 + 이유인지가 다음 설계 결정에 중요함. + 2. "시도 2"(함수 인자로 넘기기)도 같은 결과가 나오는가, 아니면 대입과 + 함수 인자 전달이 Luau에서 다르게 취급되는가(공변성 처리 차이 가능성). + 3. A가 전부 막히면 -> .claude/base/modifier-plan.md 9-2번의 fallback대로 + `Overridden(...: any): any`로 확정하고 이 항목을 M7에서 다시 열 것. + A가 통과하면 -> 서브타입 체이닝을 정식으로 타입에 반영할 수 있다는 + 뜻이니 그 결과를 modifier-plan.md에 반영할 것. +]] diff --git a/.claude/luau-test/10-roblox-studio-checks.server.luau b/.claude/luau-test/10-roblox-studio-checks.server.luau new file mode 100644 index 0000000..93a5d5c --- /dev/null +++ b/.claude/luau-test/10-roblox-studio-checks.server.luau @@ -0,0 +1,206 @@ +--[[ + 검증 대상 (Roblox Studio 전용 — 순수 luau CLI로는 안 됨, 실제 Instance/ + Connection/CollectionService/Attribute가 필요함): + + A) bindLifetime/unbindLifetime/canExecute의 gcconn 트릭 — Observer/ + Effect 값의 이중 바인딩을 canBound로 막는지까지 포함해서 검증 + (2026-08-09 세션에 unbindLifetime 추가 + canBound 이름 확정 + + gchold를 배열이 아니라 value를 키로 쓰는 테이블로 바꾼 것까지 반영 + — 이전 버전의 이 스크립트는 array 기반 gchold였음, 이번에 정정). + B) Attribute가 Instance 참조 타입을 실제로 지원하는가(ObjectValue + 없이 Ref 용도로 쓸 수 있다는 CLAUDE.md 서술의 실측). + C) CollectionService 태그 + GetTagged 왕복이 quad-debug가 기대하는 + 대로 동작하는가(태그 추가/제거, GetTagged로 조회). + + 배경: .claude/base/lifecycle-pattern.md "bindLifetime/canExecute/ + unbindLifetime — 확정" 절 + "실측 필요(M0/M2)" 캐비엇, + .claude/base/bind-system-plan.md "이중 바인딩 금지" 절(canBound), + CLAUDE.md 2026-08-06 세션의 Attribute Instance 참조 지원 언급, + .claude/research/debug-tooling-plan.md의 CollectionService 노출 방식. + + 실행 방법: + 1. Roblox Studio에서 아무 place나 열고(빈 baseplate로 충분), + ServerScriptService에 이 파일 내용을 그대로 붙여넣은 Script를 + 하나 만든다. + 2. Play(F5) 또는 Run(F8) — Output 창에서 결과를 확인. + 3. 확인 끝나면 이 Script는 지워도 됨(Studio 안에 실제로 만든 Script + 얘기 — 이 원본 파일 자체는 `.claude/luau-test/`에 참고용으로 + 남겨둠). + + 주의: HUMAN_TODO.md 1번(Studio 별도 계정) 확인 후 실행할 것 — + SAFETY.md 준수. +]] + +print("========================================") +print("A) bindLifetime/unbindLifetime/canExecute/canBound gcconn 트릭") +print("========================================") + +do + local relate = {} -- 이 스파이크 전용 아주 단순한 strong map (inst -> {gcconn, gchold}) + + -- Observer/Effect를 흉내낸 최소 값 — .Subscribed 필드가 canExecute/canBound가 + -- 공유하는 그 필드(base/bind-system-plan.md "이중 바인딩 금지" 절 참고) + local function fakeObserver() + return { isObserverSpike = true, Subscribed = false } + end + local function isObserverLike(v) + return type(v) == "table" and v.isObserverSpike == true + end + + -- canBound(handle) — "아직 어느 경로로도 안 묶였으면 true" + local function canBound(value) + return not (isObserverLike(value) and value.Subscribed) + end + + local function bindLifetime(inst: Instance, value: any) + local isOE = isObserverLike(value) + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + + local entry = relate[inst] + if not entry then + local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로 + local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() + -- 이 콜백은 정상적으로는 절대 발화하면 안 됨 — 발화하면 그 자체가 + -- "ClassName이 신호를 절대 안 쏜다"는 가정이 틀렸다는 증거이므로 경고. + warn("[예상 밖] ClassName Changed가 실제로 발화함! gcconn 트릭의 전제가 깨짐:", inst:GetFullName()) + local _ = gchold + end) + entry = { gcconn = gcconn, gchold = gchold } + relate[inst] = entry + end + entry.gchold[value] = true -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC + if isOE then + value.Subscribed = true -- canExecute/canBound가 보는 필드 그대로 재사용 + end + end + + local function unbindLifetime(inst: Instance, value: any) + local entry = relate[inst] + if entry then + entry.gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제(O(1)) + end + if isObserverLike(value) then + value.Subscribed = false + end + end + + local function canExecute(inst: Instance, value: any): boolean + if isObserverLike(value) and not value.Subscribed then + return false + end + local entry = relate[inst] + return entry ~= nil and entry.gcconn.Connected + end + + local target = Instance.new("Folder") + target.Name = "QuadLifetimeSpikeTarget" + target.Parent = workspace + + local obs1 = fakeObserver() + bindLifetime(target, obs1) + print("bindLifetime 직후 canExecute(target, obs1) =", canExecute(target, obs1), "(true여야 함)") + + print() + print("-- A-1) canBound 이중 바인딩 게이트: 같은 obs1을 또 bindLifetime하면 error가 나야 함 --") + local ok, err = pcall(function() + bindLifetime(target, obs1) + end) + print("두 번째 bindLifetime(obs1) 성공?", ok, "(false여야 함)", not ok and tostring(err) or "") + + print() + print("-- A-2) unbindLifetime: 특정 값 하나만 조기 해제, inst 전체엔 영향 없어야 함 --") + local obs2 = fakeObserver() + bindLifetime(target, obs2) + print("obs2 bindLifetime 직후 canExecute =", canExecute(target, obs2), "(true)") + unbindLifetime(target, obs2) + print("obs2 unbindLifetime 이후 canExecute =", canExecute(target, obs2), "(false여야 함, .Subscribed가 다시 false)") + print("obs1(같은 inst, 안 건드림)은 여전히 canExecute =", canExecute(target, obs1), "(true여야 함 — obs2 해제가 obs1에 영향 없어야 함)") + print("unbindLifetime 이후 같은 obs2를 다시 bindLifetime 가능한가(canBound가 재바인딩 허용하는지)?") + local ok2 = pcall(function() + bindLifetime(target, obs2) + end) + print("재-bindLifetime(obs2) 성공?", ok2, "(true여야 함 — unbindLifetime이 canBound를 다시 통과시켜야 함)") + + print() + print("-- A-3) Destroy 시 canExecute가 false로 바뀌는가(gcconn.Connected 확인) --") + target:Destroy() + print( + "Destroy 후 canExecute(target, obs1) =", + canExecute(target, obs1), + "(false여야 함 — Connection.Connected가 Destroy로 즉시 끊기는지 확인)" + ) + + -- 5초 정도 대기하며 위 warn이 늦게라도 튀어나오는지 관찰(비동기 우려 대비) + task.delay(5, function() + print("[A] 5초 대기 종료 — 그 사이 warn이 안 떴다면 gcconn 트릭 전제가 안전함") + end) +end + +print() +print("========================================") +print("B) Attribute의 Instance 참조 타입 지원 여부") +print("========================================") + +do + local target = Instance.new("Folder") + target.Name = "QuadAttributeRefSpikeTarget" + target.Parent = workspace + + local holder = Instance.new("Folder") + holder.Name = "QuadAttributeRefSpikeHolder" + holder.Parent = workspace + + local ok, err = pcall(function() + holder:SetAttribute("RefToTarget", target) + end) + print("SetAttribute(Instance) 성공?", ok, err and tostring(err) or "") + + if ok then + local readBack = holder:GetAttribute("RefToTarget") + print("GetAttribute 결과가 원본과 같은 Instance인가?", readBack == target) + end + + -- 대상이 Destroy되면 Attribute는 어떻게 되는가(참고 확인 — nil로 풀리는지, + -- 아니면 죽은 참조를 계속 들고 있는지는 Ref 설계에 영향을 줄 수 있음) + target:Destroy() + task.wait() + local afterDestroy = holder:GetAttribute("RefToTarget") + print("target Destroy 후 GetAttribute =", afterDestroy, "(nil로 풀리는지, 죽은 참조 그대로인지 확인)") + + holder:Destroy() +end + +print() +print("========================================") +print("C) CollectionService 태그 + GetTagged 왕복") +print("========================================") + +do + local CollectionService = game:GetService("CollectionService") + local TAG = "QuadDebugSpikeTag" + + local a = Instance.new("Folder") + a.Name = "TaggedA" + a.Parent = workspace + local b = Instance.new("Folder") + b.Name = "TaggedB" + b.Parent = workspace + + CollectionService:AddTag(a, TAG) + CollectionService:AddTag(b, TAG) + + local tagged = CollectionService:GetTagged(TAG) + print("GetTagged 결과 개수 =", #tagged, "(2여야 함)") + + CollectionService:RemoveTag(a, TAG) + local taggedAfterRemove = CollectionService:GetTagged(TAG) + print("RemoveTag 이후 GetTagged 개수 =", #taggedAfterRemove, "(1이어야 함)") + + a:Destroy() + b:Destroy() +end + +print() +print("모든 섹션 실행 완료 — Output 로그를 그대로 복사해서 공유해주면 됨") diff --git a/.claude/luau-test/11-modifier-illegal-value-error.luau b/.claude/luau-test/11-modifier-illegal-value-error.luau new file mode 100644 index 0000000..29a8d31 --- /dev/null +++ b/.claude/luau-test/11-modifier-illegal-value-error.luau @@ -0,0 +1,302 @@ +--[[ + 검증 대상: 2026-08-09 세션에 "UB, 방어 없음"에서 "즉시 error"로 전환된 + 두 규칙이 실제 Luau에서 자연스럽게 짜이는지 (신규 파일 — 이 폴더의 + 1차 작성 이후 새로 확정된 내용이라 이걸 검증하는 스크립트가 + 없었음): + + A) Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ + Modifier)이 들어오면 제네릭 __index 셋터가 최종 저장 직전에 즉시 + error. State/Source 값은 여전히 허용. + B) State/Source 자체의 "확정되는 값"(Source:Set, Store({defaults}) + 생성 시 각 default, State:Compute(fn)의 캐싱 직전)이 Modifier이면 + 즉시 error. Slot/Tag/Attribute/Tween 같은 다른 핸들러 계층 값은 + 여전히 허용(Modifier만의 예외). + + 배경: .claude/base/modifier-plan.md "Modifier 필드에 핸들러 계층 값이 + 들어오면 즉시 error" 절 + "7. State/Source가 Modifier를 값으로 담는 + 것 — 명시적 error로 확정" 절(둘 다 2026-08-09 세션 정정, 이전엔 + "UB, 가능하면 타입으로 막을 것"이었음). + + 실행: `luau 11-modifier-illegal-value-error.luau` +]] + +-- ===== Brand 흉내 — 실제로는 base/bind-system-plan.md의 Brand 절이 다루는 +-- weak-key 레지스트리 기반이지만, 이 스파이크에선 태그 필드로 단순화 ===== + +local function tag(name) + return function(t) + return setmetatable(t or {}, { __index = { __brand = name } }) + end +end + +local function brandOf(v) + if type(v) ~= "table" then + return nil + end + local mt = getmetatable(v) + return mt and mt.__index and mt.__index.__brand +end + +local makeRef = tag("Ref") +local makePreRef = tag("PreRef") +local makeObserver = tag("Observer") +local makeEffect = tag("Effect") +local makeSlot = tag("Slot") + +local function isRef(v) + return brandOf(v) == "Ref" +end +local function isPreRef(v) + return brandOf(v) == "PreRef" +end +local function isObserver(v) + return brandOf(v) == "Observer" +end +local function isEffect(v) + return brandOf(v) == "Effect" +end +local function isSlot(v) + return brandOf(v) == "Slot" +end + +-- ===== Modifier — 제네릭 __index 셋터 + "핸들러 계층 값 즉시 error" 체크 ===== + +local ModifierBrand = {} +local function isModifier(v) + return type(v) == "table" and v[ModifierBrand] == true +end +local function isState(v) + -- Source가 State를 구조적으로 만족(store-semantics.md) — 여기선 둘 다 + -- ".__isStateLike" 태그로 단순화해서 흉내 + return type(v) == "table" and v.__isStateLike == true +end + +local function illegalModifierFieldValue(v) + return isRef(v) or isPreRef(v) or isObserver(v) or isEffect(v) or isSlot(v) or isModifier(v) +end + +local function Modifier(initial) + local self = initial and table.clone(initial) or {} + self[ModifierBrand] = true + return setmetatable(self, { + __index = function(t, key) + -- 제네릭 setter 합성(modifier-plan.md 4번 절의 __index 트릭) + return function(selfArg, arg) + local clone = table.clone(selfArg) + local value + if type(arg) == "function" and not isState(selfArg[key]) then + -- plain 필드 + 함수 인자: 즉시 호출해 값 확정 (State 분기는 이 스파이크에서 생략) + value = arg(selfArg[key]) + else + value = arg + end + + -- 핵심 체크 지점: 최종 저장 직전 + if illegalModifierFieldValue(value) then + error( + string.format( + "Modifier 필드 '%s'에 핸들러 계층 값(%s)을 저장할 수 없음", + tostring(key), + tostring(brandOf(value) or (isModifier(value) and "Modifier") or "?") + ) + ) + end + + clone[key] = value + return clone + end + end, + }) +end + +print("=== A. Modifier 필드에 핸들러 계층 값 -> 즉시 error ===") + +local mod = Modifier() + +local casesA = { + { name = "plain 리터럴(허용)", fn = function() + return mod:FontSize(20) + end, expectError = false }, + { name = "State 유사 값(허용)", fn = function() + return mod:TextColor(setmetatable({ __isStateLike = true }, {})) + end, expectError = false }, + { name = "Ref(금지)", fn = function() + return mod:SomeField(makeRef()) + end, expectError = true }, + { name = "PreRef(금지)", fn = function() + return mod:SomeField(makePreRef()) + end, expectError = true }, + { name = "Observer(금지)", fn = function() + return mod:SomeField(makeObserver()) + end, expectError = true }, + { name = "Effect(금지)", fn = function() + return mod:SomeField(makeEffect()) + end, expectError = true }, + { name = "Slot(금지)", fn = function() + return mod:SomeField(makeSlot()) + end, expectError = true }, + { name = "다른 Modifier(금지)", fn = function() + return mod:SomeField(Modifier()) + end, expectError = true }, + { + name = "변환 함수가 Ref를 반환(금지 — 콜백이어도 최종값만 봄)", + fn = function() + return mod:SomeField(function(old) + return makeRef() + end) + end, + expectError = true, + }, +} + +for _, case in casesA do + local ok, err = pcall(case.fn) + local pass = (ok == not case.expectError) + print( + string.format( + " [%s] %s: ok=%s expectError=%s %s", + pass and "PASS" or "FAIL", + case.name, + tostring(ok), + tostring(case.expectError), + (not ok) and ("(error: " .. tostring(err) .. ")") or "" + ) + ) +end + +-- ===== B. State/Source가 확정하는 값이 Modifier면 즉시 error ===== + +print() +print("=== B. Source:Set / Store 생성 / State:Compute 캐싱 -> Modifier면 즉시 error ===") + +local function checkNotModifier(value, where) + if isModifier(value) then + error(where .. ": Modifier를 State/Source 값으로 저장할 수 없음") + end +end + +local function Source(default) + checkNotModifier(default, "Source(default)") + local self = { __isStateLike = true, value = default } + function self:Get() + return self.value + end + function self:Set(v) + checkNotModifier(v, "Source:Set") + self.value = v + end + function self:Compute(fn) + local derived = { __isStateLike = true, dirty = true } + function derived:Get() + if self.dirty then + local result = fn(self.value) + checkNotModifier(result, "State:Compute 캐싱") + derived.cached = result + derived.dirty = false + end + return derived.cached + end + return derived + end + return self +end + +local function Store(defaults) + local sources = {} + for k, v in defaults or {} do + sources[k] = Source(v) -- 여기서도 checkNotModifier가 자연히 걸림 + end + return sources +end + +local casesB = { + { + name = "Source(plain 초기값) — 허용", + fn = function() + return Source(1) + end, + expectError = false, + }, + { + name = "Source(Modifier 초기값) — 금지", + fn = function() + return Source(Modifier()) + end, + expectError = true, + }, + { + name = "source:Set(plain) — 허용", + fn = function() + local s = Source(1) + s:Set(2) + end, + expectError = false, + }, + { + name = "source:Set(Modifier) — 금지", + fn = function() + local s = Source(1) + s:Set(Modifier()) + end, + expectError = true, + }, + { + name = "Store({defaults}) 중 하나가 Modifier — 금지", + fn = function() + return Store({ Health = 100, Style = Modifier() }) + end, + expectError = true, + }, + { + name = "state:Compute(fn)이 Modifier를 반환 — Get() 호출 시점에 금지", + fn = function() + local s = Source(1) + local derived = s:Compute(function(v) + return Modifier() + end) + derived:Get() -- 캐싱 시점에 걸려야 함 + end, + expectError = true, + }, + { + name = "state:Compute(fn)이 Slot을 반환 — 허용(Modifier만의 예외)", + fn = function() + local s = Source(1) + local derived = s:Compute(function(v) + return makeSlot() + end) + derived:Get() + end, + expectError = false, + }, +} + +for _, case in casesB do + local ok, err = pcall(case.fn) + local pass = (ok == not case.expectError) + print( + string.format( + " [%s] %s: ok=%s expectError=%s %s", + pass and "PASS" or "FAIL", + case.name, + tostring(ok), + tostring(case.expectError), + (not ok) and ("(error: " .. tostring(err) .. ")") or "" + ) + ) +end + +--[[ + 확인 포인트: + 1. 모든 케이스가 "PASS"로 찍히는가 — FAIL이 있으면 어느 케이스인지, + 기대와 실제가 어떻게 달랐는지 알려줄 것. + 2. A의 마지막 케이스("변환 함수가 Ref를 반환")처럼 "콜백이 반환한 값"도 + 리터럴과 동일하게 잡히는지 — modifier-plan.md가 명시한 "콜백이냐 + 직접 실행이냐를 구분하지 않고 최종 저장값 하나만 본다"는 원칙의 핵심. + 3. B에서 Slot 같은 "Modifier가 아닌 다른 핸들러 계층 값"은 State/Source에 + 여전히 자유롭게 들어갈 수 있는가(Modifier만의 예외라는 걸 재확인). + 4. 이 스파이크는 Brand/isState를 태그 필드로 단순화한 것 — 실제 구현은 + base/bind-system-plan.md의 weak-key 레지스트리 기반 Brand를 씀, + 여기선 그 판별 로직 자체가 아니라 "체크 지점 배치가 실제로 동작하는가"만 + 검증 대상. +]] diff --git a/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau b/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau new file mode 100644 index 0000000..75a7074 --- /dev/null +++ b/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau @@ -0,0 +1,93 @@ +--!strict +--[[ + 검증 대상: `[Attribute<> "name"] = value`처럼 제네릭 파라미터로 + 타입을 명시하는 특수 DI 키를 테이블 리터럴에 쓸 때, `=` 뒤 `value`의 + 타입이 실제로 그 제네릭 파라미터로 좁혀지는지 — Luau 타입 솔버가 + "이 계산된 키의 제네릭 인스턴스에 따라 옆 값의 타입이 달라진다"는 + 이질적인(heterogeneous) 매핑을 실제로 풀 수 있는지가 핵심. + + 배경: .claude/base/attribute-plan.md "[실측 필요, M0/M10]" 절 + (2026-08-09 열한 번째 세션에 새로 명시된 항목 — base 문서 자신이 + "미검증"이라고 못박아둔 몇 안 되는 곳). 문서 원문: "Luau 솔버가 이 + 조합을 못 풀면 value가 any로 남을 수 있음 — 단, 타입 추론이 안 + 되더라도 런타임 동작에는 영향 없음". 이 스크립트는 그 예상을 실제 + Luau로 확인하는 것. + + 실행: `luau-analyze 12-type-attribute-generic-key-narrowing.luau` + (또는 luau-lsp로 이 파일을 열어 인라인 진단을 확인 — 사용자가 직접 + luau-lsp로 확인할 예정) + + 참고: 이건 Roblox 실제 SetAttribute API 타입이 아니라, "제네릭 DI 키 + + 테이블 리터럴 값 타입 연동"이라는 메커니즘 자체만 최소로 흉내낸 + 것 — Roblox 전역 타입이 필요 없어서 luau-lsp의 sourcemap 없이도 + 그대로 확인 가능함. +]] + +-- SpecialKey — Attribute<>(name)이 반환하는 "타입이 실린 키" 흉내 +type SpecialKey = { __attributeKeyBrand: T } + +local function Attribute(name: string): SpecialKey + return (nil :: any) :: SpecialKey +end + +-- ===== 시도 1: 동질적(homogeneous) 인덱스 시그니처 — 항상 통과해야 함(비교군) ===== +-- 이 방식은 "이 테이블의 모든 특수 키가 전부 boolean 값이어야 한다"는 +-- 고정된 단일 인스턴스라, 애초에 여러 타입을 섞을 수 없음 — 진짜 검증 +-- 대상이 아니라 대조군. +type HomogeneousParams = { + [SpecialKey]: boolean, +} + +local homo: HomogeneousParams = { + [Attribute("Enabled")] = true, -- 이건 당연히 통과해야 함 +} + +-- ===== 시도 2: 이질적(heterogeneous) — 한 테이블에 boolean/number Attribute를 섞음 ===== +-- 이게 진짜 검증 대상: SpecialKey의 T가 키마다 달라도 값이 그 T로 +-- 각각 좁혀지는가? (TypeScript의 mapped/conditional type이 있어야 되는 +-- 문제 — Luau에 해당 기능이 없으면 아래 셋 중 하나가 일어날 것으로 예상: +-- (a) 두 번째 대입에서 타입 에러, (b) 값 타입이 조용히 any/union으로 +-- 뭉개짐, (c) 테이블 타입 자체를 선언하는 시점에 에러) + +local mixedProps: { [SpecialKey]: any } = {} -- 일단 any로 도피한 버전(항상 통과할 것) +mixedProps[Attribute("Enabled")] = true +mixedProps[Attribute("Count")] = 5 + +-- 진짜 물어볼 질문: 개별 대입 표현식 하나만 놓고 봤을 때, Luau가 +-- `Attribute(name)`의 제네릭 인스턴스화 결과로 옆의 값 리터럴 타입을 +-- 체크/추론해주는지 — 함수 호출 결과 타입과 그 옆 대입값 사이의 관계는 +-- "인덱스 시그니처"가 아니라 그냥 "함수 반환 타입에 맞는 변수 대입" +-- 문제로 좁혀서 아래처럼 직접 테스트: + +local function setAttributeTyped(key: SpecialKey, value: T) + -- 실제로는 여기서 SetAttribute(inst, name, value)를 호출하겠지만, + -- 이 스파이크는 타입 추론 자체만 봄 +end + +setAttributeTyped(Attribute("Enabled"), true) -- T=boolean으로 추론돼 통과해야 함 +setAttributeTyped(Attribute("Count"), 5) -- T=number로 추론돼 통과해야 함 +setAttributeTyped(Attribute("Enabled"), 5) -- <- 여기가 핵심: T=boolean인데 5(number)를 넘김. +-- 이게 타입 에러로 잡히면(기대하는 결과) "제네릭 키 함수 호출 패턴"은 +-- 최소한 함수 인자 형태로는 잘 작동한다는 뜻 — 그럼 테이블 리터럴 +-- `{[Attribute<>(name)] = value}` 안에서도 Luau가 "이건 사실 +-- 위 setAttributeTyped 호출과 같은 형태"로 취급해주는지가 다음 질문. + +print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것") +print(homo, mixedProps) + +--[[ + 확인 포인트 (luau-analyze / luau-lsp): + 1. `setAttributeTyped(Attribute("Enabled"), 5)` 줄에서 실제로 타입 + 에러가 나는가? — 나면 "함수 인자 형태의 제네릭 키+값 연동"은 + Luau가 지원한다는 뜻. + 2. 위가 통과한다면, 그 다음으로 `mixedProps[Attribute("Enabled")] = + 5`처럼 **인덱스 대입 문법**으로도 같은 체크가 되는지 직접 추가해 + 실험해볼 것(이 파일엔 일부러 안 넣어둠 — `{[SpecialKey]: T}`류 + 제네릭 인덱스 시그니처를 실제로 선언할 수 있는지부터 luau-lsp가 + 에러를 내는지 먼저 볼 것). + 3. 최종적으로 "제네릭 DI 키를 테이블 리터럴 안에서 쓸 때 값 타입이 + 실제로 좁혀지는지"에 대한 결론이 나오면 attribute-plan.md의 + "[실측 필요, M0/M10]" 캐비엇을 그 결과로 갱신할 것 — 안 되는 걸로 + 확인되면 "정적 체크는 `BooleanAttribute`류 정적 타입 패밀리 쪽만 + 신뢰 가능"이라는 문서의 fallback 결론이 확정됨. +]] diff --git a/.claude/luau-test/13-type-ref-preref-subtype.luau b/.claude/luau-test/13-type-ref-preref-subtype.luau new file mode 100644 index 0000000..495aa4b --- /dev/null +++ b/.claude/luau-test/13-type-ref-preref-subtype.luau @@ -0,0 +1,135 @@ +--!strict +--[[ + 검증 대상: 2026-08-09 열한 번째 세션(커밋 f198fd9)에서 뒤집힌 결정 — + `isRef`/`isPreRef`가 "서로 배타적인 형제 브랜드"에서 "Source가 State를 + 만족하는 것과 같은 포함 관계(PreRef가 Ref의 하위 개념)"로 재정정됨. + 이전엔 `isRef(preRefInstance) == false`였는데, 지금은 + `isRef(preRefInstance) == true`로 바뀜. + + 이 파일은 두 부분으로 나뉨: + A) 타입 체크 대상 — `PreRef`가 구조적으로 `Ref`를 만족하는지 + (08번 파일이 Source/State에 대해 검증한 것과 정확히 같은 질문을 + Ref/PreRef에 대해 재검증). + B) 런타임 대상 — `isRef`/`isPreRef` predicate 합성이 문서에 적힌 대로 + 동작하는지, 그리고 `Dispatch/Leaf.luau`의 `(v=Ref)` 매치 핸들러가 + 이제 `isHandlable = isRef(v) and not isPreRef(v)`로 **명시적으로 + 좁혀야만** PreRef를 잘못 삼키지 않는다는 것. + + 배경: .claude/base/bind-system-plan.md의 `Brand` 절 + ("isRef(x)는 그 위에 Brand.get(x)==RefTag를 OR로 얹은 상위 개념")와 + "`(v=Ref)` children 배열 leaf 매치 핸들러... isRef(v) and not + isPreRef(v)로 명시적으로 좁혀야 함" 부분. + + 실행: + A) `luau-analyze 13-type-ref-preref-subtype.luau` (또는 luau-lsp) + B) `luau 13-type-ref-preref-subtype.luau` (런타임 부분은 그냥 통과함, + 타입 에러가 있어도 런타임 실행 자체는 대부분 luau CLI가 그냥 + 진행시켜줌 — 확실히 하려면 A/B를 따로 luau-analyze/luau로 각각 + 돌려볼 것) +]] + +-- ===== A) 타입 체크 대상 ===== + +export type Ref = { + Value: T, + Set: (self: Ref, value: T) -> Ref, + Callback: (self: Ref, fn: (T) -> ()) -> Ref, + Wait: (self: Ref, thread: thread?) -> Ref, +} + +-- PreRef는 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 문서가 +-- 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 차이는 +-- 런타임 전용이라 정적 타입엔 안 드러남, 아래서 별도 nominal 표시로만 구분) +export type PreRef = { + Value: T, + Set: (self: PreRef, value: T) -> PreRef, + Callback: (self: PreRef, fn: (T) -> ()) -> PreRef, + Wait: (self: PreRef, thread: thread?) -> PreRef, +} + +local function fakePreRef(default: T): PreRef + return (nil :: any) :: PreRef +end + +-- 시도: PreRef 값을 Ref가 필요한 자리에 그대로 넘길 수 있는가 +local function useAsRef(r: Ref): T + return r.Value +end + +local myPreRef: PreRef = fakePreRef(0) +local viaSubtype: number = useAsRef(myPreRef) -- <- 여기가 luau-analyze 확인 포인트 + +print("A) 타입 체크는 luau-analyze/luau-lsp로 확인 — 런타임은 그냥 통과") +print(viaSubtype) + +-- ===== B) 런타임 대상 — Brand/isRef/isPreRef predicate 합성 ===== + +local Brand = {} +local registry = setmetatable({}, { __mode = "k" }) +function Brand.set(x, tag) + registry[x] = tag +end +function Brand.get(x) + return registry[x] +end + +local RefTag, PreRefTag = {}, {} + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isRef(x) + -- 재정정된 합성 — PreRef가 Ref의 하위 개념(OR로 얹음) + return isPreRef(x) or Brand.get(x) == RefTag +end + +local function makeRef() + local self = {} + Brand.set(self, RefTag) + return self +end +local function makePreRef() + local self = {} + Brand.set(self, PreRefTag) + return self +end + +local ref1 = makeRef() +local preref1 = makePreRef() + +print() +print("=== B-1. isRef/isPreRef 기본 동작 ===") +print("isRef(ref1) =", isRef(ref1), "(true여야 함)") +print("isPreRef(ref1) =", isPreRef(ref1), "(false여야 함 — Ref는 PreRef가 아님)") +print("isRef(preref1) =", isRef(preref1), "(true여야 함 — 2026-08-09 재정정의 핵심)") +print("isPreRef(preref1) =", isPreRef(preref1), "(true여야 함)") + +-- Dispatch/Leaf.luau의 (v=Ref) 매치 핸들러 흉내 — PreRef를 잘못 삼키면 안 됨 +local function leafRefHandlerIsHandlable(v) + return isRef(v) and not isPreRef(v) +end + +print() +print("=== B-2. Leaf의 (v=Ref) 핸들러가 PreRef를 잘못 삼키지 않는가 ===") +print("leafRefHandlerIsHandlable(ref1) =", leafRefHandlerIsHandlable(ref1), "(true — 일반 Ref는 처리해야 함)") +print( + "leafRefHandlerIsHandlable(preref1) =", + leafRefHandlerIsHandlable(preref1), + "(false여야 함 — PreRef는 pre-pass가 이미 처리했어야 하고, 이 핸들러가 또 삼키면 안 됨)" +) + +assert(leafRefHandlerIsHandlable(ref1) == true, "일반 Ref가 Leaf 핸들러에서 거부됨 (버그)") +assert(leafRefHandlerIsHandlable(preref1) == false, "PreRef가 Leaf 핸들러에 잘못 잡힘 (버그 — 2026-08-09 재정정이 요구하는 명시적 좁히기 실패)") +print() +print("assert 전부 통과 — isRef(v) and not isPreRef(v) 조합이 기대대로 동작함") + +--[[ + 확인 포인트: + A) luau-analyze/luau-lsp에서 `viaSubtype` 줄이 에러 없이 통과하는가 — + 08번 파일이 Source/State에 대해 확인했던 것과 같은 결론(구조적 + 서브타이핑 성립)이 Ref/PreRef에도 그대로 적용되는지. + B) 런타임 assert가 전부 통과하는가 — 특히 `isRef(preref1) == true` + (뒤집힌 결정 자체)와 `leafRefHandlerIsHandlable(preref1) == false` + (그 뒤집힘 때문에 Leaf 핸들러가 이제 반드시 `not isPreRef(v)`를 + 같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지. +]] diff --git a/.claude/luau-test/14-type-nilable-default-overload.luau b/.claude/luau-test/14-type-nilable-default-overload.luau new file mode 100644 index 0000000..3d365a9 --- /dev/null +++ b/.claude/luau-test/14-type-nilable-default-overload.luau @@ -0,0 +1,84 @@ +--!strict +--[[ + 검증 대상: `Source(default)`/`Ref(default)`의 `default` 인자를 생략할 수 + 있는 건 오직 `T`가 nilable(`T?`)일 때뿐이라는 캐비엇(2026-08-09 + 열한 번째 세션, 커밋 f198fd9 신규) — "타입으로 막을 수 있으면 막고 + 안 되면 UB로 문서 경고"라고 base 문서가 적어둔 부분을 실제로 타입 + 오버로드로 막을 수 있는지 검증. + + 배경: .claude/base/bind-system-plan.md "[보강, 2026-08-09 열한 번째 + 세션] Source(default)/Ref(default)의 default 인자가 '선택'이라는 + 서술은 정확히는 T가 nil을 포함할 때만 성립함" 절. 문제 상황: + `Ref()`(default 생략)를 만들면 실제 런타임 값은 `nil`인데 + `T=number`(non-nilable)라고 선언하면 타입과 실제 값이 어긋남 — + 특히 `:Callback(fn)`이 등록 즉시 그 시점 값(nil)으로 1회 호출되므로 + 이 어긋남이 바로 드러남. + + 시도할 두 가지 설계: + A) 단일 시그니처 `Ref(default: T?): Ref` — default를 항상 + optional로 열어둠. 이러면 `Ref()`가 타입 에러 없이 + 통과해버려서(캐비엇을 막지 못함) 이게 바로 지금 실제로 벌어지고 + 있는 문제 상황. + B) 오버로드 흉내 — `default: T` 필수 시그니처와 `(): Ref` + 무인자 시그니처 두 개를 함수 타입 교차(`&`)로 합쳐, "생략하면 + 자동으로 반환 타입이 T?로 바뀐다"를 강제할 수 있는지. + + 실행: `luau-analyze 14-type-nilable-default-overload.luau` (또는 + luau-lsp) +]] + +export type Ref = { + Value: T, + Set: (self: Ref, value: T) -> Ref, +} + +-- ===== A) 단일 시그니처 — default가 항상 optional(현재 캐비엇이 실제로 벌어지는 형태) ===== + +local function RefA(default: T?): Ref + return (nil :: any) :: Ref +end + +local refA1: Ref = RefA(5) -- 정상 — 통과해야 함 +local refA2: Ref = RefA() -- <- 문제의 그 케이스: default 생략, T=number(non-nilable)인데 +-- 통과해버리면(기대되는 나쁜 결과) 이게 바로 캐비엇이 막고 싶어하는 구멍 — +-- 런타임엔 .Value가 nil인데 타입은 number라고 거짓말하는 상태가 됨. + +-- ===== B) 오버로드 흉내 — 함수 타입 교차로 "생략 시 T?" 강제 시도 ===== + +type RefCtorOverload = ((default: T) -> Ref) & (() -> Ref) + +local RefB: RefCtorOverload = (nil :: any) :: RefCtorOverload + +local refB1: Ref = RefB(5) -- 정상 — 첫 번째 오버로드(T=number)로 통과해야 함 +local refB2 = RefB() -- 두 번째 오버로드로 잡혀야 함 — 추론된 타입이 Ref 류가 될 것으로 예상 +-- 아래가 진짜 확인 대상: refB2를 non-nilable Ref에 대입하면 막히는가? +local refB2_annotated: Ref = RefB() -- <- 이것도 에러가 나야 "막혔다"고 할 수 있음 +-- (T가 추론 컨텍스트에서 number로 잡히면서 동시에 "무인자 오버로드라 T? +-- 여야 한다"는 두 요구가 충돌하는지가 관건 — 충돌해서 에러가 나면 성공, +-- 조용히 number로 통과해버리면 오버로드로도 못 막는다는 뜻) + +-- 대조군 — nilable로 명시하면 항상 통과해야 함(오버로드가 정상 케이스는 안 막는지 확인) +local refB3: Ref = RefB() + +print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것") +print(refA1, refA2, refB1, refB2, refB2_annotated, refB3) + +--[[ + 확인 포인트: + 1. A) `refA2 = RefA()` 줄이 에러 없이 통과하는가? (예상: 통과함 — + 이게 바로 "타입으로 못 막는" 현재 상태를 보여주는 대조군) + 2. B) `refB2_annotated: Ref = RefB()` 줄이 에러가 나는가? + - 에러가 나면: 오버로드 방식으로 실제로 이 캐비엇을 타입 레벨에서 + 막을 수 있다는 뜻 — base 문서의 "타입으로 막을 수 있으면 막을 것" + 을 실제 설계로 채택할 근거가 생김, `Source`/`Ref` 생성자를 + 이 오버로드 모양으로 다시 쓸 것. + - 에러가 안 나면(조용히 통과): Luau의 제네릭 함수 교차 타입 + 오버로드가 이 정도로 정교한 추론을 못 한다는 뜻 — 문서의 + "안 되면 UB로 경고"가 fallback이 아니라 사실상 유일한 선택지로 + 확정됨. + 3. `refB3`(nilable로 명시한 정상 케이스)는 항상 통과하는가 — 오버로드 + 자체가 정상 사용까지 막아버리는 부작용은 없는지 확인. + 4. 이 결과가 나오면 `bind-system-plan.md`의 해당 캐비엇 절에 "실측 + 결과"로 반영할 것 — 지금은 "타입으로 막을 수 있으면 막고"라는 + 조건문으로만 적혀 있어서 결론이 필요함. +]] diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md new file mode 100644 index 0000000..8826776 --- /dev/null +++ b/.claude/luau-test/README.md @@ -0,0 +1,118 @@ +# .claude/luau-test — M0 착수 전 실 Luau 기술검증 스파이크 모음 + +**[2026-08-09 이동]** 처음엔 레포 루트 `luau-ignoreme/`(git 자동 제외 +폴더)에 만들었으나, 사용자가 직접 확인해볼 만한 검증 코드라 커밋해서 +레포에 남기기로 함 — `.claude/luau-test/`로 옮기고 일반 추적 대상으로 +전환(더 이상 `*-ignoreme*` gitignore 패턴에 안 걸림). 위치만 바뀌었을 뿐 +내용/역할은 그대로 — 아직 M0가 공식 시작 전인 상태에서 미리 돌려보는 +사전 검증 스파이크 모음. + +## 왜 이게 필요한가 + +`.claude/base/`와 `ROADMAP.md` M0가 "추론만으로 확정하고 실제 Luau 코드로 +부딪혀본 적 없는 것"으로 명시적으로 지목한 항목들, 그리고 이후 세션들에서 +"M0/M2 스파이크 검증 목록에 추가됨"으로 흩어져 있던 항목들을 모아 각각 +독립 실행 가능한 스크립트로 만들었음. **내가(에이전트) 직접 실행은 못 +했음** — 이 환경엔 `luau`/`luau-analyze` 바이너리가 없어서, 전부 사용자가 +직접 돌려보고 결과를 알려줘야 함. + +각 파일 맨 위 주석에 다음이 전부 적혀있음: 뭘 검증하는지, 어느 base 문서/ +ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해야 하는지. + +## 실행 환경 세 갈래 + +| 환경 | 필요한 것 | 해당 파일 | +|---|---|---| +| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분) | +| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14 | +| **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 | + +**12/13/14는 특히 `luau-lsp`로 확인해달라고 요청받은 것들** — `luau-analyze`도 +같은 타입 솔버를 쓰므로 원리적으로는 같은 결과가 나와야 하지만, `luau-lsp`가 +에디터에서 인라인으로 에러 위치/메시지를 보여줘서 "정확히 어느 표현식이 +막히는지"를 확인하기 더 편함. sourcemap/Roblox 전역 타입 없이도 그대로 +확인 가능하게 만들어뒀음(전부 순수 Luau 타입 문법만 씀). + +로컬에 `luau`/`luau-analyze`가 없으면 위 GitHub 릴리즈에서 플랫폼에 맞는 +바이너리를 받으면 됨. Roblox Studio 파일은 스크립트 내용을 그대로 +`ServerScriptService`에 붙여넣은 `Script`로 만들어 Play(F5)하면 됨. + +## 파일 목록 — 뭘 검증하는지 요약 + +| 파일 | 검증 대상 | 근거 문서 | +|---|---|---| +| `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 | +| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | +| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | +| `04-dispatch-chain-retractUnder.luau` | `Dispatch` 체인 + `retractUnder`가 다단(A→B→C) 재-dispatch에서 정확한지 | `bind-system-plan.md` "Dispatch 체인", 2026-08-08 세 번째 세션 | +| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | +| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | +| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" | +| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 | +| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | +| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | +| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 | +| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[Attribute<> "name"] = value`처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | +| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | +| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `bind-system-plan.md` "[보강, 2026-08-09 열한 번째 세션]" 절 | + +## 갱신 이력 + +**1차 (2026-08-09 저녁, `8169b90`~`5836c2d` 반영)**: 01/02/05/06/07/08/09는 +검증 대상 API가 그대로였고, `03`/`04`에 참고 노트 추가, `10` Part A 갱신 +(canBound/unbindLifetime 반영), `11` 신규 추가. + +**2차 (2026-08-09 커밋 `f198fd9`, "중간검토(질문 모드)에서 발견된 설계 +결함 다수 수정" 반영)** — 사용자가 직접 `.claude/base/` 전체를 훑으며 +찾은 정정들 중 이 폴더(당시 `luau-ignoreme/`)에 영향 있는 것만: + +- `02`: **전면 재작성.** 이전 버전은 "Ref 콜백/대기자 배열도 None으로 + 소진해야 한다"고 잘못 적어뒀는데, 이게 실제로는 무한 성장 버그였음이 + 드러나 `nil`로 되돌아감(순서가 안 중요하고 슬롯 재사용이 필요한 + 배열은 `nil`, 순서가 중요한 배열(PreRef pre-pass/sourceList)은 + 계속 `None` — 두 카테고리로 나눠 각각 재현). +- `12`/`13`/`14`: **신규 추가.** 사용자 요청으로 "타입 관련 실측 필요 + 항목, 특히 luau-lsp로 확인해야 하는 것"을 새로 찾아 만듦 — Attribute + 제네릭 DI 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 + + `isRef`/`isPreRef` 재정정(13), Source/Ref의 nilable-default 캐비엇을 + 오버로드로 막을 수 있는지(14). 셋 다 base 문서가 "미검증"/"실측 필요" + 로 스스로 표시해둔 지점이거나(12, 14) 이번 f198fd9에서 뒤집힌 결정 + (13)이라 기존 파일 중 커버하는 게 없었음. +- `01`/`03`~`11`(위 02 제외)은 f198fd9의 다른 변경(Slot CRUD 인덱스 + 기준 전환, Source 리프 직접 바인딩 정상 경로 재확인, Dispatch 직접 + 호출 UB 명시, Tag retract 전제 명시, Attribute 타입 파라미터화 확정 + 등)과 대조해본 결과 검증 대상 API에 영향 없어 안 건드림. + +**3차 (2026-08-09, 폴더 이동)**: `luau-ignoreme/` → `.claude/luau-test/`로 +이동, git 추적 대상으로 전환. 내용 변경 없음 — 경로 참조하는 문구만 +동기화. + +## 결과 확인 후 할 일 + +각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면 +`.claude/base/` 문서를 그 자리에서 고침(ROADMAP.md M0 통과 기준 그대로: +"안 되면 여기서 관련 base/ 문서부터 고치고 재시도"). 특히: + +- `08`/`09`가 luau-analyze에서 에러를 내면 어떤 정확한 에러 메시지인지가 + 다음 타입 설계 방향(펼쳐 쓰기 vs `any` fallback)을 결정하는 데 중요함. +- `07`이 예상대로 GC가 안 되는 것처럼 보이면(90개 안 죽는 것 같으면), + `collectgarbage("count")` 수치 변화를 같이 알려줄 것 — 정확한 판정이 + 어려운 항목이라 참고 신호로만 쓸 것. +- `10`의 A 섹션에서 만약 `warn`이 실제로 뜨면(ClassName Changed가 + 발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것. + A-2(재-bindLifetime 허용 여부)가 실패하면 `canBound`/`unbindLifetime` + 설계 자체를 재검토해야 함. +- `11`은 전부 PASS가 기대값 — FAIL이 하나라도 있으면 어느 케이스인지 + 그대로 알려줄 것(특히 "변환 함수가 반환한 값" 케이스는 놓치기 쉬운 + 경로라 실제 구현에서도 잘 짜였는지 중요한 신호). +- `02`의 Part B-2("None + table.insert" 대조군)가 실제로 배열 길이 1000까지 + 자라는 게 확인되면 사용자가 찾은 버그가 정량적으로 재현된 것 — 반대로 + 안 자란다면 정정 근거 자체를 재검토해야 하니 꼭 알려줄 것. +- `12`/`14`는 **어느 쪽으로 나와도 유용한 정보** — 통과하면 그 타입 + 패턴을 실제 설계로 채택, 실패하면 `any`/정적 타입 패밀리로 fallback한다는 + 각 파일의 결론 그대로 base 문서에 반영하면 됨. 정확한 luau-lsp 에러 + 메시지(어느 줄, 어떤 문구)를 그대로 붙여서 알려주면 다음 문서 갱신이 + 빠름. +- `13`은 A(타입)/B(런타임) 둘 다 확인해줄 것 — B의 assert가 실패하면 + `Dispatch/Leaf.luau` 설계(`isRef(v) and not isPreRef(v)`) 자체가 + 잘못 짜인 것이니 우선순위 높게 알려줄 것. diff --git a/CLAUDE.md b/CLAUDE.md index e6b6184..f8a641d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,13 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 pre-implementation-audit.md`(2026-08-06 신설)의 우선순위1 항목부터 먼저 확인할 것** — 특히 M0 스파이크 코드 자체에 반영해야 할 항목(props.Modifier/ Ref를 안 넘기는 케이스 포함, `store.key` 레코드 필드 타이핑도 M0로 - 앞당기기 검토)이 있음, 아래 최신 세션 요약 참고. + 앞당기기 검토)이 있음, 아래 최신 세션 요약 참고. **M0 실제 착수 전, + `.claude/luau-test/`(2026-08-09 신설)의 사전 검증 스파이크 결과부터 + 확인할 것** — M0가 공식 짜야 할 스파이크와 겹치는 항목들을 미리 + 독립 스크립트로 만들어 사용자가 `luau`/`luau-analyze`/`luau-lsp`/ + Roblox Studio로 직접 돌려보기로 한 상태, 아직 결과 미확인. 걸리는 + 게 있으면 `base/` 문서부터 고치고, 없으면 그대로 M0 실제 코드 작성에 + 재사용하면 됨(README 참고). 2. **용어 정리 — 사용자가 별도로 요청, 진행 중.** "register"(v1) 같이 부정확한 이름들을 전체적으로 재검토하자는 요청 — 1차 제안 완료(우선순위 순: `State`가 React/Vue식 "쓸 수 있는 로컬 상태"라는 통상 의미와 반대라 @@ -2426,3 +2432,63 @@ Blocker 전체, 소스트리/네이밍 컨벤션/Handler 3분류/테스트 전 전 상태가 더 탄탄해졌을 뿐 우선순위 자체는 그대로. 이 중간검토가 마지막 배치(6단계)까지 끝났는지, 사용자가 이어서 더 볼 부분이 있는지는 다음 세션 시작 시 확인. + +## 2026-08-09 열두 번째 세션 — `.claude/luau-test/` 신설: M0 사전 검증 +스파이크 작성, 결과는 아직 미확인 + +M0가 공식적으로 짜야 할 스파이크(위 "지금 할 일" 1번, `ROADMAP.md` M0 +체크박스)와 지금까지 세션 로그 곳곳에 흩어져 있던 "실제 Luau로 부딪혀본 +적 없는 것"/"M0/M2 스파이크 검증 목록에 추가됨" 표시들을 한 곳에 모아, +사용자가 직접 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 돌려볼 +수 있는 독립 실행 스크립트 14개 + `README.md` 색인으로 만듦. 세 라운드에 +걸쳐 진행됨: + +1. **1차 작성** — 레포 루트 `luau-ignoreme/`(당시엔 git 자동 제외 폴더로 + 시작)에 M0 체크리스트 5개 항목(Store/State 다이아몬드 전파, Source가 + State를 구조적으로 만족하는 제네릭 타입, process/retract 재귀 디스패치, + 배열/해시 두 패스 순회, `props.Modifier or None` nil-hole 관용구) + + `Dispatch` 체인/`retractUnder` 다단 검증, `Relate`의 weak-table GC + 실측, `Modifier.Overridden` 서브타입 타입체크, Roblox 전용 + `bindLifetime`/`canExecute`/Attribute Instance 참조/`CollectionService` + 태그 확인까지 10개 파일 작성(01~10). +2. **2차 — 커밋 `f198fd9`("중간검토에서 발견된 설계 결함 다수 수정") 반영.** + 그 사이 사용자가 직접 `.claude/base/` 전체를 훑으며 여러 결함을 + 정정(위 절 참고) — 그 중 `02`(Ref 콜백/대기자 배열의 소진 센티널이 + `None`→`nil`로 되돌아간 것, 실제로 `None`을 쓰면 배열이 무한정 + 자라는 버그였음이 드러남)이 luau-test 내용과 정면으로 어긋나 전면 + 재작성(순서가 중요한 배열은 계속 `None`, 순서 무관+슬롯 재사용 + 필요한 배열은 `nil`이라는 최종 구분 + 무한 성장 버그의 정량적 + 재현까지 포함). `Modifier` UB→error 전환(11 신규)도 이 라운드에 + 같이 반영. 나머지 파일은 대조 결과 영향 없음을 서브에이전트+직접 + 문서 대조로 확인. +3. **3차 — 사용자 요청으로 "타입 관련 실측 필요, 특히 `luau-lsp`로 + 확인해야 할 것" 3개 추가(12~14).** base 문서 자신이 "실측 필요"라고 + 명시적으로 못박아둔 지점(`attribute-plan.md`의 `[Attribute<> + "name"] = value` 제네릭 DI 키가 실제로 값 타입을 좁혀주는지, 12번)과 + f198fd9에서 뒤집힌 결정(`isRef`/`isPreRef`가 이제 `Source`/`State`와 + 같은 포함 관계 — `PreRef`가 `Ref`의 하위 개념이 됨, `PreRef`가 + `Ref`를 구조적으로 만족하는지 타입체크까지 포함, 13번), 그리고 + 같은 세션에 새로 명시된 캐비엇(`Source(default)`/`Ref(default)`의 + `default` 생략은 `T`가 nilable일 때만 안전하다는 것을 함수 오버로드로 + 타입 레벨에서 실제로 막을 수 있는지, 14번)을 찾아 작성. +4. **폴더 이동 — `luau-ignoreme/` → `.claude/luau-test/`.** 사용자가 + "커밋해서 레포에 남기자"고 판단 — `*-ignoreme*` gitignore 패턴을 + 벗어나 일반 추적 대상으로 전환, `.claude/README.md`에 새 폴더 행 + 추가. 내용/역할은 안 바뀜, 경로 참조 문구만 동기화. + +**아직 아무것도 실행 안 됨 — 에이전트도 로컬에 `luau`/`luau-analyze`가 +없어서 직접 못 돌려봤고, 사용자가 다음에 `luau`/`luau-analyze`/ +`luau-lsp`/Roblox Studio로 직접 돌려보고 결과를 알려주기로 함.** 결과에 +따라 할 일: +- 전부 통과 → M0 실제 착수 시 이 스크립트들의 로직을 그대로 재사용하며 + 진행. +- 하나라도 걸림(특히 12/14의 타입 narrowing 실패, 07의 GC 신호 이상, + 10의 `warn` 발생, 13의 런타임 assert 실패) → 해당 `base/` 문서를 + 그 자리에서 정정. +- `.claude/luau-test/README.md`의 "결과 확인 후 할 일" 절에 파일별로 + 뭘 우선 확인해야 하는지 이미 적어둠 — 다음 세션은 그 응답을 + 대조하는 것부터 시작하면 됨. + +**다음 세션이 할 일**: 사용자가 luau-test 실행 결과를 갖고 오면 그것부터 +반영. 아직 없으면 `ROADMAP.md` M0 착수 우선순위는 그대로(위 "지금 할 일" +1번 참고) — 단, 이 폴더 결과를 먼저 확인하고 진행하는 게 순서. From 85e467152c3e63cbf7947cef5e54d52d1894d4fc Mon Sep 17 00:00:00 2001 From: qwreey Date: Mon, 10 Aug 2026 00:09:29 +0900 Subject: [PATCH 59/62] =?UTF-8?q?decide(base):=20Slot:Add=EA=B0=80=20?= =?UTF-8?q?=EC=82=BD=EC=9E=85=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=B0=98?= =?UTF-8?q?=ED=99=98,=20=EB=B2=94=EC=9C=84=20=EB=B0=96=20index=EB=8A=94=20?= =?UTF-8?q?clamp=20=EB=8C=80=EC=8B=A0=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .claude/base/slot-plan.md | 16 +++++++++++++++- CLAUDE.md | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 2b7bab2..96edfc4 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -217,7 +217,7 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` | 연산 | 시그니처 | 복잡도 | 의미 | |---|---|---|---| -| `Add` | `Slot:Add(element, index?)` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 | +| `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 배열(순서 보존)을 반환 | @@ -227,6 +227,16 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` | `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) 시프트 재발생)를 따로 불러야 해서 이중으로 @@ -254,6 +264,10 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` 있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을 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`와 동일한 검증 diff --git a/CLAUDE.md b/CLAUDE.md index f8a641d..a7bbb32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2492,3 +2492,27 @@ M0가 공식적으로 짜야 할 스파이크(위 "지금 할 일" 1번, `ROADMA **다음 세션이 할 일**: 사용자가 luau-test 실행 결과를 갖고 오면 그것부터 반영. 아직 없으면 `ROADMAP.md` M0 착수 우선순위는 그대로(위 "지금 할 일" 1번 참고) — 단, 이 폴더 결과를 먼저 확인하고 진행하는 게 순서. + +## 2026-08-10 세션 — `Slot:Add`가 삽입 인덱스를 반환하도록 확정, 범위 밖 +`index`는 clamp 대신 error + +짧은 세션. 사용자가 "`Slot:Add`/`Remove`가 어차피 void인데 삽입된 인덱스를 +반환해줘도 되지 않냐"고 제기 — 검토 후 채택, `base/slot-plan.md`의 CRUD +표/에러 조건 절에 반영 완료: + +- **`Slot:Add(element, index?): number`로 확정** — `index` 생략(끝에 추가) + 시 호출부가 실제 위치를 모르는 문제를 `Add`가 이미 계산해서 아는 값을 + 그냥 반환하는 것으로 공짜 해결(기존엔 `IndexOf`로 O(n) 역조회해야 했음). + `Move`/`Swap`이 void인 것과 안 부딪힘 — 그 둘은 호출부가 이미 위치를 + 알고 부르는 연산이라 새 정보가 없어 void인 거고, `Add`는 반대로 새 + 정보(계산된 위치)가 생기는 경우라 "반환값은 실제로 새로 알게 되는 + 정보만"이라는 같은 원칙의 연장. +- **`Add`의 `index`가 범위 밖(1..현재 개수+1)이면 즉시 `error()`, clamp + 안 함 — 사용자가 직접 근거 제시.** clamp는 "의도한 위치가 아닌데 조용히 + 성공하는" 찾기 힘든 버그 유형을 새로 만들 뿐이고, 이미 `Remove`/ + `Extract`/`Move`/`Swap` 전부가 범위 밖에서 즉시 에러인 fail-fast 톤과도 + 맞아야 함. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인이 +여전히 먼저) — 이번 세션은 이미 확정된 CRUD 표의 작은 갭 하나만 메운 +것이라 우선순위엔 영향 없음. From 577277823a84dce6e8fd3951df3939038f2227c1 Mon Sep 17 00:00:00 2001 From: qwreey Date: Mon, 10 Aug 2026 00:11:12 +0900 Subject: [PATCH 60/62] =?UTF-8?q?docs(base):=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=9E=90=EC=8B=9D=20=EC=B6=94=EA=B0=80/=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=EB=8A=94=20Slot/state=EB=A7=8C=20=EC=A0=95=EB=8B=B9,=20?= =?UTF-8?q?=EA=B7=B8=20=EC=99=B8=EB=8A=94=20UB=EB=A1=9C=20=EB=AA=85?= =?UTF-8?q?=EB=AC=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slot의 Length/Offset 카운팅이 Dispatch.setLength/setOffsetSource 호출에 의존하는데, 이 둘을 우회하는 경로(외부에서 직접 .Parent = inst로 자식을 끼워 넣는 것)가 UB라는 게 문서 어디에도 명시돼 있지 않았던 갭을 보강. Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 14 ++++++++++++++ .claude/base/slot-plan.md | 8 ++++++++ CLAUDE.md | 26 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index a18869f..67b6b6f 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -582,6 +582,20 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n `base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로 해소됨 — 상세는 그 문서 참고. +**동적 자식 추가/제거의 유일한 정당 경로는 `Slot` 또는 `state`류 +store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Offset` +카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind +프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를 +호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는 +부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent = +parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래 +추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게 +만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB. +`Slot`이든 `state`이든 둘 다 이미 이 두 API를 정확히 호출하는 +유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절 +참고) — 새 경로를 만들 필요 없이 "동적 자식은 반드시 이 둘 중 하나를 +거쳐야 한다"는 규칙만 문서화하면 됨. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 2b7bab2..b29d5fc 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -666,6 +666,14 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 `Length`가 그걸 못 잡는 게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫. +**동적 자식은 반드시 `Slot` 또는 `state`류 store-bind를 통해서만 +추가/제거 — 그 외 경로는 UB(2026-08-10 세션, `base/bind-system-plan.md`의 +"Length/Offset" 절 반영).** 둘 다 `Dispatch.setLength`/`setOffsetSource`를 +정확히 호출하는 유일한 정당 경로라, 이걸 우회해서(예: 외부 코드가 Slot이 +마운트해둔 부모 Instance에 직접 `.Parent = parentInst`로 자식을 끼워 +넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라 +조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. + ## 백로그 — `Slot():Single(state, updateFn?)` (2026-08-09 여섯 번째 세션, 미착수) `:List`의 key-map(`mounted`/`userdata`/`keyIndex`) 없이 "0개 아니면 1개"만 diff --git a/CLAUDE.md b/CLAUDE.md index f8a641d..561ac59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2492,3 +2492,29 @@ M0가 공식적으로 짜야 할 스파이크(위 "지금 할 일" 1번, `ROADMA **다음 세션이 할 일**: 사용자가 luau-test 실행 결과를 갖고 오면 그것부터 반영. 아직 없으면 `ROADMAP.md` M0 착수 우선순위는 그대로(위 "지금 할 일" 1번 참고) — 단, 이 폴더 결과를 먼저 확인하고 진행하는 게 순서. + +## 2026-08-10 세션 — 동적 자식 추가/제거는 `Slot`/`state`만 정당, +그 외는 UB로 명문화(문서 갭 보강) + +사용자 질문에서 시작: Slot이 마운트한 객체 수를 `Length`/`Offset` +누적합으로 세는 방식(2026-08-09 여섯 번째 세션 확정)이 되면서, 이 카운팅을 +안 거치고 quad가 관리하는 부모 Instance에 외부에서 직접 `.Parent = inst`로 +자식을 끼워 넣는 게 UB로 문서화돼 있는지 확인 요청 — 검토 결과 **문서 +어디에도 명시돼 있지 않은 진짜 갭**이었음(기존 UB 목록엔 Handler 순환/ +이중 바인딩/`Dispatch.process` 우회 직접 호출/`setLength`·`setOffsetSource` +생략 등은 있었지만 이 케이스는 빠져있었음, 인접했던 "수동 Visible 토글은 +Length가 못 잡는 게 맞다"는 캐비엇은 이미 마운트된 element를 나중에 +숨기는 별개 시나리오라 이것과 다름). + +**확정**: 동적 자식 추가/제거의 유일한 정당 경로는 `Slot` 또는 +`state`류 store-bind 뿐 — 둘 다 그 위치의 Handler가 +`Dispatch.setLength`/`Dispatch.setOffsetSource`를 정확히 호출하는 것으로 +이미 보장돼 있음. 이 두 경로를 거치지 않고 quad가 마운트해둔 부모 +Instance에 직접 `.Parent =` 대입으로 자식을 넣거나 빼면 `lengthList`/ +`sourceList`가 그 변화를 전혀 몰라 `Length` 카운트와 형제 순서(offset) +계산이 조용히 어긋남 — 새 방어 로직 없이 UB로 문서화만 함(다른 UB +케이스들과 같은 톤). `base/bind-system-plan.md`("Length/Offset" 절 +말미)/`base/slot-plan.md`("Slot.Length" 절 말미)에 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 +우선) — 이번 세션은 순수 문서 갭 보강이라 우선순위엔 영향 없음. From 7466e9216b8220a4dc4450e90e969b374ab0f509 Mon Sep 17 00:00:00 2001 From: qwreey Date: Mon, 10 Aug 2026 00:53:26 +0900 Subject: [PATCH 61/62] =?UTF-8?q?decide(tween):=20=EA=B0=92-=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20Tween=20=EB=9E=98=ED=8D=BC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=84=A4=EA=B3=84,=20pre-implementation-audit=201-1=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 독립 Dispatch 핸들러("v가 Store인 아무 k나 잡는 우선순위 최상위 핸들러") 모델을 PropertyHandler가 소비하는 값-레벨 래퍼(Tween)로 전환. State/ Source 언랩(범용 StoreBind)과 "이 값이 트윈 대상인가" 판단을 분리해, 일반 반응형 프로퍼티 바인딩이 Tween 파일을 거쳐가는지 불명확했던 구조적 모호함(pre-implementation-audit.md 1-1)을 해소. - Tween.Value는 plain T만(반응성은 바깥 :Compute가 전담, 이중 경로 방지) - hasBeenSet+활성 엔진 트윈을 3-상태 릴레이션 슬롯(RobloxTween|true|nil) 하나로 통합, 첫 세팅은 항상 애니메이션 없이 스냅 - 활성 트윈 정리 후에만 새 값 세팅(순서 뒤바뀌면 값이 덮어써질 위험) - 타입은 T'=T|Tween 치환만으로 기존 T|State 모양에 자동 통합 - useTween은 :Apply(Animate(...))로 해소, 새 옵션 필드 불필요 - PropertyHandler가 항상 매치되는 유일한 핸들러가 되어 Tween↔프로퍼티 handler-switch에 의존하던 retract 케이스가 사라짐 구 모델은 archive/tween-special-bind-key-reversed.md로 보존. 코퍼스 전체(bind-system-plan.md/architecture.md/modifier-plan.md/ROADMAP.md/ question.md/README.md/attribute-plan.md)의 stale Tween 참조 동기화, CLAUDE.md 세션 요약 추가. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 3 +- .../tween-special-bind-key-reversed.md | 68 ++++ .claude/base/architecture.md | 5 +- .claude/base/attribute-plan.md | 7 +- .claude/base/bind-system-plan.md | 118 +++--- .claude/base/modifier-plan.md | 36 +- .claude/question.md | 12 +- .claude/research/pre-implementation-audit.md | 12 +- .claude/research/tween-plan.md | 356 ++++++++++++------ CLAUDE.md | 114 ++++++ ROADMAP.md | 35 +- 11 files changed, 574 insertions(+), 192 deletions(-) create mode 100644 .claude/archive/tween-special-bind-key-reversed.md diff --git a/.claude/README.md b/.claude/README.md index 748ba9b..298fd35 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -54,7 +54,7 @@ | 문서 | 내용 | 우선순위 | |---|---|---| -| `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel, 트윈 옵션 값 모양(TweenInfo vs 편의 필드)은 신규 열린 논의 | 중 — 세부 옵션만 남음 | +| `tween-plan.md` | **[2026-08-10 세션 재설계]** 트윈을 값-레벨 `Tween` 래퍼(PropertyHandler가 소비)로 전환, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`로 이전. 3-상태 릴레이션 슬롯(`RobloxTween\|true\|nil`)으로 hasBeenSet 억제, `T'=T\|Tween` 타입 치환, `useTween`은 `:Apply`/`:Compute`로 해소. 기본 오버라이드는 Cancel, 트윈 옵션 값 모양(TweenInfo vs 편의 필드)만 남음 | 중 — 세부 옵션만 남음 | | `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | @@ -80,6 +80,7 @@ | `observer-cleanup-contract-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** `Observer` 자체에 React `useEffect`식 cleanup 반환 계약을 추가하는 안 — 클로저로 이미 충분해 기각, `Effect`가 opt-in 상위 계층으로 이 패턴을 제공 | | `keyed-collection-state-method-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두려던 초안 — Source 미사용 컴포넌트가 접근 못 한다는 반례로 철회, 현재는 자유 함수로 확정 | | `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 | +| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween` 래퍼 모델로 완전히 대체됨(`research/tween-plan.md`) | ## 참고 diff --git a/.claude/archive/tween-special-bind-key-reversed.md b/.claude/archive/tween-special-bind-key-reversed.md new file mode 100644 index 0000000..82775ab --- /dev/null +++ b/.claude/archive/tween-special-bind-key-reversed.md @@ -0,0 +1,68 @@ +# [역전됨] Tween = 우선순위 최상위 store-bind Dispatch 핸들러(`[Tween(key,tweenData...)] = storeValue`) — 값-레벨 `Tween` 래퍼로 대체됨 + +**역전 일시**: 2026-08-10. **원 확정 일시**: 2026-08-04(로드맵 인수인계 +라운드 전후, "확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다" 최초 작성). +**현재 유효한 설계**: `research/tween-plan.md`(전면 재작성됨)가 최종 소스. +이 파일은 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 "Tween이 곧 +범용 store-bind 핸들러"였던 모델에서 "Tween은 PropertyHandler가 소비하는 +값-레벨 래퍼"로 넘어갔는지가 `quadnomicon` 소재로 가치 있어서 사유·원문을 +통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**메커니즘**: Tween을 `[Tween(key, tweenData...)] = storeValue` 형태의 +특수 bind key로 제공. `k`는 무엇이든 받고 `v`가 Store(반응형 값)인 경우를 +잡아내는, **우선순위가 매우 높은 Dispatch 핸들러**. 처음 실행될 때는 +그냥 바인드로 필드를 쓰지만, 이후에는 store 값을 핸들해서 바뀔 때마다 +트윈을 처리: + +``` +[Tween(key, tweenData...)] = storeValue +``` + +핸들러 내부에서: (1) 라이프타임(`Connected`) 확인, (2) 사용자가 넘긴 +함수들을 거쳐 실제 값(`realv`) 계산, (3) `Dispatch.retractUnder`로 자기 +밑을 정리한 뒤 `realv`를 들고 `Dispatch.process(inst, k, realv)`를 재귀 +호출 — "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 원칙의 +구체 사례. + +**override 정책**: 기본값 Cancel, 나머지 세 옵션(오버라이드/삭제 후 +재시작/끝점 이동 후 재시작)은 `retract(inst, k, v)`가 이전 값을 받아 +처리 — 이 부분은 새 모델에서도 그대로 유지됨(PropertyHandler 내부 +로직으로 위치만 이동). + +## 왜 역전됐나 + +`research/pre-implementation-audit.md` 우선순위1-1이 지적한 구조적 +모호함이 출발점 — 이 문서 전체에서 "`v`가 store인 값을 구독해 `realv`로 +재귀 process하는" 범용 메커니즘의 유일한 구체 예시가 항상 "Tween"으로만 +등장했음. 그런데 Tween(실제 애니메이션, override/cancel 정책)은 명백히 +더 좁고 별개인 기능이라, `Frame { BackgroundColor3 = store.color }`처럼 +애니메이션 없이 그냥 반응형으로 값만 바뀌길 원하는 가장 흔한 케이스가 +(a) 결국 이름은 "Tween"인 파일을 거쳐가며 "애니메이션 없음"으로 처리되는 +건지, (b) 각 핸들러가 범용 `Dispatch/StoreBind.luau` 유틸을 독립적으로 +써야 하는 건지 문서가 정하지 않은 상태로 남아있었음. + +2026-08-10 세션에서 사용자가 직접 제기한 재설계 방향("Tween 프리미티브를 +`V`에 넣는 식, 최종 Property가 알아서 `V`가 `isTween`이면 트윈 넣도록")으로 +해소 — State/Source 언랩(범용 StoreBind)과 "이 값이 트윈 대상인가" 판단을 +완전히 분리해, 후자를 Dispatch 우선순위 경쟁이 아니라 PropertyHandler +내부의 평범한 값 분기로 옮김. + +## 대체 모델과의 비교 + +| | 구 모델(우선순위 최상위 Dispatch 핸들러) | 신 모델(값-레벨 `Tween` 래퍼) | +|---|---|---| +| 매치 방식 | `isHandlable(inst,k,v) = isState(v)` — Tween이 범용 StoreBind 역할까지 겸함 | 범용 `StoreBind`가 State/Source를 언랩, `Tween` 여부는 `realv`를 받은 PropertyHandler가 직접 판단 | +| "애니메이션 없는 일반 반응형 바인딩"의 정체 | 불명확(이름이 Tween인 파일을 거쳐가는지 문서가 안 정함) | 명확함 — 그냥 `Dispatch/StoreBind.luau`, Tween과 완전히 무관 | +| 핸들러 타입 전환 | Tween↔프로퍼티 핸들러 사이에서 실제로 바뀜 → `retract`가 이 케이스의 대표 예시였음 | 항상 PropertyHandler 하나만 매치 → 이 `retract` 케이스 자체가 사라짐, 전환은 3-상태 릴레이션 슬롯으로 내부 처리 | +| 트윈 대상 값 타입 | Store 전체(`T`뿐 아니라 임의 반응형 값) | `Tween = {Value: T, ease...}` — `Value`는 plain `T`만, 반응성은 바깥 `:Compute`가 전담 | +| 진입 애니메이션 억제 | 별도 논의 없음 | 3-상태 슬롯(`RobloxTween\|true\|nil`)의 `hasBeenSet` 분기로 자동 해결 | + +부수적으로, 이 역전은 Tag가 이미 겪었던 것과 같은 종류의 단순화 — +"핸들러 *타입*이 실제로 안 바뀌면 `retract`가 필요 없어진다"는 결론을 +Tween에도 적용한 셈. Tag 역전(`archive/tag-hash-key-model-reversed.md`)이 +"핸들러 타입이 안 바뀐다는 전제가 실사용에서 깨졌다"는 방향이었다면, +Tween 역전은 반대로 "핸들러 타입을 애초에 안 바뀌게 재설계해서 전제 +자체를 성립시켰다"는 방향 — 같은 `retract`/핸들러-전환 문제를 서로 +반대 방향에서 접근한 두 사례로 대비해볼 만함(quadnomicon 소재). diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index a157fe7..982a850 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -138,6 +138,7 @@ quad/ │ ├── 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 세 번째 세션) @@ -156,13 +157,13 @@ quad/ ├── 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 + │ ├── 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 기반 자동 판별 │ ├── Attribute.luau │ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) - │ ├── Tween.luau # 높은 우선순위 store-bind 핸들러 │ ├── 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 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index f00c1c7..f3fa874 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -61,9 +61,10 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att - **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상 같은 `AttributeHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜). `retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜", - Tween↔일반 프로퍼티가 실사례)에 해당 안 함 — `bind-system-plan.md` - "확정된 디스패치 모델" 절이 한때 Attribute도 retract 필요 예시로 들었던 - 걸 여기서 바로잡음. + `Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이 + 패턴의 예시가 아님, `research/tween-plan.md`)에 해당 안 함 — + `bind-system-plan.md` "확정된 디스패치 모델" 절이 한때 Attribute도 + retract 필요 예시로 들었던 걸 여기서 바로잡음. - store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store`/`State` 값도 받음). diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 67b6b6f..9b59486 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -48,8 +48,9 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 **`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는 담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 - 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: Tween↔ - 프로퍼티 교체) 실제로 담당이 바뀌는 순간 `attempt to call a nil value`로 + 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: `Tag(...)`↔ + `nil` 교체 — `base/tag-plan.md`) 실제로 담당이 바뀌는 순간 `attempt to + call a nil value`로 바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가). @@ -84,9 +85,9 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널 절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상 그냥 `process`로 계속 씀. -- 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 - 잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 store이면 그 값을 처리(구독)함. - 이 핸들러 안에서: +- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고 + `v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — + `v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서: 1. 지금 이 처리가 실행되어도 되는지 라이프타임(`Connected`)을 확인 — 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 @@ -98,10 +99,15 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 - 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 store가 - 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 통과 못 - 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 - 재귀 걱정 없음. + 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이 + 아니라면 자연히 `StoreBind`의 `isHandlable`을 통과 못 하고 우선순위상 + 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음. + **[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind + 핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가 + 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며(`research/ + tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이 + 자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이 + 패턴의 남은 대표 예시. - **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — @@ -111,18 +117,23 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. - **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는 핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째 - 세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행 - 중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반 - PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게 - `retract`의 일. **Attribute는 여기 해당 안 함** — UICorner 숏핸드와 - 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 - 담당, 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 - 안 일어남 — `base/attribute-plan.md`. **[정정, 2026-08-08 세 번째 - 세션] Tag는 더 이상 여기 해당하지 않음** — array-part 값 객체로 - 재설계되며(`base/tag-plan.md`, 구 모델은 `archive/ - tag-hash-key-model-reversed.md`) `Tag(...)`↔`nil` 사이에서 핸들러 - 타입 자체가 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 - 바뀌는 diff는 `process`가 담당. + 세션, 정정) — 예: `Tag(...)`↔`nil` 사이에서 핸들러 타입 자체가 + 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는 + diff는 `process`가 담당(`base/tag-plan.md`, 2026-08-08 세 번째 세션 + — array-part 값 객체 재설계 이후, 구 모델은 `archive/ + tag-hash-key-model-reversed.md`). **Attribute는 여기 해당 안 함** — + UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 + 같은 핸들러가 계속 담당, 추가/제거를 전부 `process` 자신이 처리)이라 + 핸들러 교체 자체가 안 일어남 — `base/attribute-plan.md`. **[정정, + 2026-08-10 세션] Tween도 더 이상 여기 해당하지 않음** — 원래는 이 + 패턴의 대표 예시("Tween 핸들러가 매치돼 애니메이션 실행 중이었는데 + 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가 + 넘어가는 경우")였으나, Tween이 독립 Dispatch 핸들러가 아니라 + PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며 + 매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스 + 자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의 + 3-상태 릴레이션 슬롯으로 처리(`research/tween-plan.md`, `archive/ + tween-special-bind-key-reversed.md`). - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 @@ -141,8 +152,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — - 별도 cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 - 자동으로 같이 죽는 것까지 포함, `research/tween-plan.md` 참고). + 별도 cleanup 로직 불필요(PropertyHandler가 여기 담아두는 실제 엔진 Tween + 인스턴스도 자동으로 같이 죽는 것까지 포함). **[정정, 2026-08-10 세션]** + Tween은 더 이상 별도 "Tween 핸들러"가 아니라 PropertyHandler 내부 + 로직이므로, 이 슬롯이 실제로 담는 값은 `RobloxTween | true | nil` + 3-상태(첫 세팅 여부까지 같은 슬롯에 통합) — 상세는 `research/ + tween-plan.md` "3-상태 저장" 절 참고. - **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. @@ -153,9 +168,9 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 둬야 할 문제가 아니라 오작동하는 handler/provider(`quad-roblox` 등) 쪽 버그로 간주 — **사용자 확정**("입력된 값이 다시 입력되면 무한루프 빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나 - 프로바이더가 잘못 짠 코드일테니까"). Tween의 store-bind 재귀 케이스(위 - 78-79행)처럼 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 - 있는 상황이 아니라고 판단해 별도 가드 없이 진행. + 프로바이더가 잘못 짠 코드일테니까"). `StoreBind`의 재귀 케이스(위 절)처럼 + 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 + 아니라고 판단해 별도 가드 없이 진행. - **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, @@ -177,13 +192,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). -### `None` 센티널 — Tween store-bind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션) +### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정) `modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는 `None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 -Tween store-bind 핸들러(65-79행)와 완전히 같은 모양의 핸들러 하나 추가.** +`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.** ``` NoneHandler.priority = <매우 높음> @@ -209,8 +224,8 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 - 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — Tween의 - store-bind 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 + 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — + `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. - **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 @@ -222,7 +237,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 - `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 → 매치된 핸들러를 `(inst,k)` 체인 꼬리에 push → 그 핸들러의 `.process` 호출. **"이전 핸들러와 다르면 retract"라는 diff는 `Dispatch.process` - 자신의 일이 아님** — 재귀/래핑 핸들러(Tween/일반 store-bind/ + 자신의 일이 아님** — 재귀/래핑 핸들러(`StoreBind`/ `NoneHandler`)가 재-dispatch 전에 스스로 `Dispatch.retractUnder(inst, k, self, newV)`를 먼저 불러 자기 밑을 정리하는 책임을 짐(정확한 메커니즘·기각된 대안은 아래 "Dispatch 체인" 절 참고 — 전역 소유자 @@ -277,7 +292,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 만들 수 있는 것)로 바꿔야 하는지 검토 후 **기각, 지금 형태(모듈 require로 바로 닿는 flat 탑레벨 함수) 유지로 확정**: -- **재귀 재-dispatch가 요구하는 필연** — Tween/`NoneHandler`/`Dispatch/ +- **재귀 재-dispatch가 요구하는 필연** — `NoneHandler`/`Dispatch/ StoreBind.luau` 전부 자기 `process` 안에서 다시 `Dispatch.process(inst,k, realv)`를 호출함(위 "확정된 디스패치 모델"/"`None` 센티널" 절). 이게 성립하려면 Dispatch가 `canExecute`/`bindLifetime`(`base/ @@ -288,11 +303,11 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 - **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두 가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약** (`isHandlable`/`priority`/`process`/`retract` 시그니처만 있는 순수 leaf, - Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`/`Tween.luau`처럼 그 계약을 + Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을 **구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존 방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau` - (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau`/ - `Tween.luau`(재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 + (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau` + (재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김. 런타임에서도 마찬가지 — 어떤 handler의 `process`든 실제로 *호출*되는 시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가 @@ -304,9 +319,11 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 `.claude/question.md`가 2026-08-08 세션에 "quad-base/quad-roblox 중 어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base, `Dispatch/Leaf.luau`, `Dispatch.addHandler`로 등록). quad-roblox의 - Property/Event/Tween 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 + Property/Event 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 - 전부 하나의 우선순위 스캔을 공유. + 전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은 + 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 + 소비되는 값-레벨 래퍼로 재설계됨(`research/tween-plan.md`). - **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 @@ -323,7 +340,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 ### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션) -**문제**: Tween/`NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 +**문제**: `NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 `Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은 `(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간 깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와, @@ -395,10 +412,13 @@ end 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는 핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만" - 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건), Tween 같은 - 경우는 자기 `Relate` 저장분만 보고 `Cancel`하면 되니 역시 `v`를 꼭 - 안 봐도 됨 — `v`는 "계약상 항상 주어지지만 안 쓰는 핸들러가 있어도 - 됨" 정도로 이해할 것. + 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건) — `v`는 "계약상 + 항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것. + **[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기 + `Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더 + 이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한 + 핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`research/ + tween-plan.md`). - **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그 @@ -676,9 +696,10 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 ## Ref — 도입 확정, 단 용도는 재정의됨 -**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(Tween 핸들러도 -`process(inst,k,v)`처럼 항상 대상 Instance를 직접 받으므로 — 위 "확정된 디스패치 -모델" 참고, `research/tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: +**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을 +실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상 +Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, `research/ +tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: - v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정 (`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유. @@ -2017,8 +2038,11 @@ T|State|nil`가 돌려주는 raw union을 사용자 코드가 분기하려면 수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부 적용됨 — `Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ `Source`/`Slot`/`None`까지, Handler 구현(`isHandlable`에서 "이 값이 -Tween인가/Store인가" 판별)과 사용자 코드 양쪽에서 반복적으로 필요해질 -수단이라 `isState` 하나만 만들고 끝내지 않고 전체를 일관된 메커니즘으로 +Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서 +"이 값이 Tween인가" 판별 — 2026-08-10 세션부터 `isTween`은 `isHandlable`이 +아니라 값-레벨 분기에서만 쓰임, `research/tween-plan.md` 참고)과 사용자 +코드 양쪽에서 반복적으로 필요해질 수단이라 `isState` 하나만 만들고 +끝내지 않고 전체를 일관된 메커니즘으로 통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 (`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index ed8dfc7..718b321 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -85,9 +85,9 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 - **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난 뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게 처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다. - 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — Tween store-bind와 + 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`도 - Tween의 store-bind 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을 + `StoreBind` 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 `process(inst, k, nil)`을 재귀 호출)이라 base 드라이버 자체엔 `None`을 아는 코드가 한 줄도 안 들어감 — 개별 프로퍼티/이벤트/UI shorthand 핸들러도 자기 시그니처에 `None`이 안 @@ -309,7 +309,7 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 `isModifier`면 캐싱 전에 `error`). 새 체크 지점을 여러 곳에 흩는 게 아니라, "값이 State/Source의 값으로 확정되는" 이미 존재하는 몇 안 되는 지점에 `isModifier` 검사 한 줄씩 얹는 것뿐. -- **Slot/Tag/Attribute/Tween 등 다른 핸들러 계층 값은 여전히 아무 +- **Slot/Tag/Attribute 등 다른 핸들러 계층 값은 여전히 아무 문제 없이 State/Source에 담길 수 있음 — Modifier만의 예외임을 명확히.** (사용자 확인: "slot은 당연히 가능함, retract도 되는 애고 런타임 값이라") 이 값들은 전부 정상적으로 `process`/`retract` @@ -319,7 +319,14 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 없음. Modifier만 유독 문제인 건 Modifier가 애초에 dispatch 경로를 아예 안 타는 유일한 존재(1번 절)라서, State/Source에 담기는 순간 "재귀 재-dispatch로 처리"할 대상 자체가 없어지기 때문 — 이 구분이 - 왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. + 왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. **[정정, + 2026-08-10 세션] `Tween`은 이 그룹에서 빠짐** — Tween이 독립 Dispatch + 핸들러(`process`/`retract`를 가진 dispatch 참가자)에서 PropertyHandler가 + 소비하는 값-레벨 래퍼로 재설계되며(`research/tween-plan.md`), `Tween`는 + 이제 `process`/`retract`가 없는 순수 raw 데이터 값 — `None`과 같은 + 분류. State/Source에 `Tween`가 담기는 것 자체는 여전히 문제없이 + 허용되지만(위 타입 대수 절 참고), 그 이유는 "재귀 dispatch 참가자라서"가 + 아니라 "그냥 raw 값이라서"로 바뀜. - **`Store`의 `T`는 Modifier가 될 수 없음(`base/store-semantics.md` "따름정리" 절)도 이 결정을 그대로 물려받음** — Source가 State를 구조적으로 만족하므로 별도로 다시 논증할 필요 없이 동일하게 적용됨. @@ -541,6 +548,27 @@ Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSou 아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로 승격하는 것뿐. +### 10. `Tween`와의 타입 합성 — `T' = T | Tween` 치환만으로 해결 (2026-08-10 세션) + +`research/tween-plan.md`가 값-레벨 `Tween` 래퍼로 재설계되며, 프로퍼티류 +Modifier 필드 setter가 트윈 값도 받을 수 있어야 하는지가 자연히 따라오는 +질문이었음 — **답은 "이미 있는 `T | State` 필드 타입 모양에 새 케이스를 +추가할 필요가 없다"** — 위 4번 절이 확정한 필드 타입 모양(리터럴 `T` 또는 +`State`)에서 "이 필드의 `T`" 자체를 `T' = T | Tween`로 치환하면 +자동으로 `T | Tween | State>`가 나옴. 즉 `FrameModifier`류 +타입 생성 스크립트가 `Position` 필드를 만들 때 그냥 `T`를 `UDim2 | +Tween`로 바꿔서 기존 setter 시그니처 생성 로직에 그대로 넣으면 됨 — +Modifier의 제네릭 `__index`/`table.clone` 런타임(위 "런타임은 클래스별 +코드 없이" 절)에도 `Tween` 인지 로직을 전혀 추가할 필요 없음(setter는 +어차피 값을 그대로 baked 저장할 뿐, 그 값이 `Tween`인지는 나중에 +PropertyHandler가 판단). + +`Tween`가 Modifier 필드로 담기는 것도, `State>`처럼 State/Source +값으로 담기는 것도 둘 다 아무 문제 없음 — 7번 절의 "핸들러 계층 값 → +error" 규칙에 안 걸림(`Tween`는 `process`/`retract`를 가진 dispatch +참가자가 아니라 `None`처럼 순수 raw 데이터 값, 위 7번 절 "Slot/Tag/Attribute +등" 목록에서 Tween을 뺀 정정 참고). + ## 열린 질문 (`.claude/question.md`에도 취합) - **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter diff --git a/.claude/question.md b/.claude/question.md index 64c09df..14ce370 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -138,10 +138,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 정해두면 싼 지연리스크) + 2개 단순화후보. 전체는 그 문서 참고, 특히 사용자 판단이 필요한 것 위주로 요약: -- **Tween.luau가 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 서술됨** — - 일반 반응형 프로퍼티 바인딩(`BackgroundColor3 = store.color`, 애니메이션 - 없음)이 결국 이름은 "Tween"인 파일을 거쳐가는 건지, 아니면 별도 범용 - `Handlers/StoreBind.luau`가 있어야 하는 건지 확정 필요 — 우선순위1-1. +- **[해소됨, 2026-08-10 세션]** Tween.luau가 "범용 store-bind 캐치올 + 핸들러"의 유일한 예시로 서술됨 — Tween을 독립 Dispatch 핸들러에서 + 값-레벨 래퍼(`Tween`, PropertyHandler가 소비)로 재설계해 해소. + 범용 State/Source 언랩은 `Dispatch/StoreBind.luau` 하나뿐, Tween 여부 + 판단은 완전히 별개(`research/tween-plan.md` 전면 재작성, 우선순위1-1 + 해소). - **[해소됨, 2026-08-09 세션]** `State`와 Ref/Slot이 Modifier 필드에 들어가는 것 — 이제 둘 다 `isX` predicate 기반 명시적 `error`로 통일(`base/modifier-plan.md` 4번/7번 절, `base/store-semantics.md` @@ -266,7 +268,7 @@ context-rejected.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` | -| 트윈 오버라이드(기본값 Cancel), 세부 옵션만 남음 | `research/tween-plan.md` | +| 트윈 — 값-레벨 `Tween` 래퍼로 구조 확정(2026-08-10), 오버라이드 기본값 Cancel, 옵션 값 모양·옵션 키 이름만 남음 | `research/tween-plan.md` | | quad2-try(폐기된 이전 시도) 리서치 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW 전부 죽은 접근으로 확인, 반복 조사 금지 | `base/bind-system-plan.md` | --- diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index bc5872b..f23b8e0 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -36,7 +36,17 @@ M0 착수 전에 확인**. 우선순위 2는 지금 결정해두면 싼데 안 ## 우선순위 1 — M0~M4 착수 전 확인 권장 -### 1-1. Tween이 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험 +### 1-1. Tween이 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험 — [해소됨, 2026-08-10 세션] + +**해소**: 아래 "제안"과 같은 방향(별개 핸들러로 분리)이 아니라, 그보다 +더 근본적인 재설계로 해소됨 — Tween 자체가 더 이상 독립 Dispatch +핸들러가 아니라 PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)로 +바뀌어, "일반 store-bind와 Tween-store-bind가 같은 핸들러인지"라는 +질문 자체가 성립하지 않게 됨 — 범용 State/Source 언랩은 `Dispatch/ +StoreBind.luau` 하나뿐이고 Tween은 그 아래에서 나온 `realv`를 +PropertyHandler가 직접 판별. 상세는 `research/tween-plan.md`(전면 +재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 아래는 +원래 발견 당시 기록. **위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 67-79행 — "Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index f8439b3..d8ea3ec 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -1,57 +1,228 @@ -# Tween / 애니메이션 플러깅 (기본값 확정, 옵션 키 이름·값 모양만 남음) +# Tween / 애니메이션 플러깅 (구조 재확정 — 값-레벨 `Tween` 래퍼, 옵션 값 모양만 남음) -**상태**: research — 방향은 뚜렷하게 잡혀 있고(라이브러리가 트윈을 직접 -구현하지 않는다), `retract` 순서/오버라이드 기본값(Cancel)도 확정됨. 남은 건 -기본값 외 나머지 오버라이드 동작을 고르는 옵션 키의 정확한 이름/시그니처와, -트윈 옵션을 어떤 값 모양으로 받을지(아래 "트윈 옵션 값 모양" 절, 신규) -정도. **중요 — 놓치기 쉬운 포인트**: `retract`는 Destroy(완전 소멸) 시엔 -호출되지 않는다(아래 "`retract`는 완전 소멸 시엔 호출되지 않는다" 절) — -Tween 오버라이드 로직을 짤 때 "인스턴스가 파괴될 때도 이 코드가 실행될 -것"이라고 가정하면 틀림. 원본: +**상태**: research — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존 +"`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은 +`research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션 +없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을 +명확히 답하지 못했음 — 대체된 새 모델(`Tween`를 PropertyHandler가 +소비하는 값-레벨 래퍼로 두는 것)이 이 모호함을 구조적으로 해소함, 아래 +"새 모델" 절부터가 최종 소스. **구 모델(특수 bind key `[Tween(key, +tweenData...)] = storeValue`)은 `archive/tween-special-bind-key-reversed.md`로 +이전됨** — 원문/역전 사유는 거기 보존, 이 문서는 새 모델만 서술. + +남은 건 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드)과 override 정책 +옵션 키의 정확한 이름/시그니처뿐 — M11 착수 시 확정. 원본: `.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은 항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이 -반응 그래프 안에 있는 설계는 명시적 반면교사 — [정정: 절 제목이 실제와 -달랐음] `reference/comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿 -("Tween/Spring이 State그래프 안의 1급 노드") 참고. +반응 그래프 안에 있는 설계는 명시적 반면교사 — `reference/ +comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿("Tween/Spring이 +State그래프 안의 1급 노드") 참고. -## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 +## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 (변경 없음) 렌더 라이브러리가 트윈을 직접 구현하는 건 처음부터 디자인이 잘못된 접근 — 렌더링 엔진(Roblox `TweenService`)의 애니메이션 관리를 전혀 안 따르게 되기 -때문. 대신: - -``` -[Tween(key, tweenData...)] = storeValue -``` - -형태의 **특수 bind key**로 제공. 처음 실행될 때는 그냥 바인드로 필드를 쓰지만, -이후에는 store 값을 핸들해서 바뀔 때마다 트윈을 처리. 아니면 사용자가 직접 -태그를 얻어 관리하게 둠(둘 다 허용 가능한 경로로 열어둘 것). +때문. quad는 트윈을 반응 그래프에 1급 노드로 편입시키지 않고, 트윈 대상 +값을 감싸는 얇은 값-레벨 래퍼(아래)로만 취급한다. **왜 이게 중요한가(Fusion 리서치에서 확인된 근거)**: Fusion은 Tween/Spring을 `GraphObject`(1급 반응 노드, `timeliness="eager"`)로 만들어서 Computed의 입력으로 자유롭게 합성 가능하게 했지만, 그 대가로 (1) 매 프레임 틱하는 외부 클럭 소스(Stopwatch/ExternalTime)를 반응 그래프에 통합해야 했고, (2) eager 재계산 노드가 무효화/전파 로직과 경쟁하게 됐고, (3) 트윈-입력 간 별도의 교차 lifetime -체크 기계장치가 필요해졌다. quad가 트윈을 특수 bind key로 빼면 이 세 가지 +체크 기계장치가 필요해졌다. quad가 트윈을 값-레벨 래퍼로 빼면 이 세 가지 복잡도를 전부 피할 수 있다 — 대신 트윈된 값이 Computed류의 추가 입력으로 -자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해). +자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해, 아래 새 모델에서도 +그대로 유지됨 — `Tween.Value`는 plain `T`만 받고 자체 반응 경로를 안 둠). -## 정정: Ref 불필요 — 핸들러는 항상 대상 Instance를 직접 받는다 +## 새 모델: `Tween` 값-레벨 래퍼, PropertyHandler가 소비 (2026-08-10 세션, 핵심 재설계) -**이전 초안의 전제가 틀렸음.** Tween 핸들러도 `base/bind-system-plan.md`의 -"확정된 디스패치 모델"을 그대로 따르는 store-bind 핸들러 중 하나 — `process(inst, -k, v)`가 항상 대상 Instance(`inst`)를 직접 받으므로, 트윈 대상을 얻기 위해 -Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 핸들러는 "`k`는 -무엇이든, `v`가 Store인 것"을 잡아내는 우선순위 매우 높은 핸들러로 등록되고, -`inst`는 이미 파라미터로 주어짐. (Ref 자체는 도입되지만 전혀 다른 -용도 — `base/bind-system-plan.md`의 Ref 절 참고.) +**동기**: 기존 모델("`k`는 무엇이든, `v`가 Store인 경우를 잡는 우선순위 +매우 높은 핸들러")은 State/Source 언랩(범용 StoreBind)과 Tween(실제 +애니메이션, 좁고 별개인 기능)을 같은 Dispatch 핸들러 하나로 뭉쳐서, +"`Frame { BackgroundColor3 = store.color }`처럼 애니메이션 없이 그냥 +반응형으로만 바뀌길 원하는 흔한 케이스가 결국 이름은 Tween인 파일을 +거쳐가는지"가 문서로 답이 안 됐음. -## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 +**해소**: State/Source 언랩(`Dispatch/StoreBind.luau`, 범용·엔진무관 — +`k`/`v`의 실제 타입과 무관하게 그냥 `isState(v)`만 보고 `realv`까지 +재귀적으로 풀어냄)은 완전히 별개로 유지하고, **"이 값이 트윈 대상인가"는 +최종 소비자(PropertyHandler)가 `realv`를 다 풀어낸 뒤 직접 판단**한다 — +별도 Dispatch 핸들러/우선순위 경쟁이 아니라, PropertyHandler 함수 내부의 +평범한 분기. -**스토어 값은 항상 먼저 캐치한다** — 그래야 `retract` 호출이 가능(이름 변경 -근거는 `base/lifecycle-pattern.md`). 이전 트윈을 취소하고 새 트윈을 만드는 게 -맞지만, "취소" 시점의 동작이 여러 갈래로 갈릴 수 있음: +```lua +Tween(opts: {Value: T, ease..., onOverride?...}) -> Tween +``` + +`Store({...})`와 같은 "`Type(args)`가 테이블 인자를 받는 팩토리" 컨벤션 — +Lua 문법상 `Tween{Value=target, ease=...}`처럼 괄호를 생략해 호출. + +**PropertyHandler.process(inst,k,realv)의 새 로직** — `realv`는 이미 +StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값: + +1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고, + `hasBeenSet` 여부만 갱신하고) 즉시 세팅. +2. `isTween(realv)`가 참이면 — 아래 "3-상태 저장" 절의 분기를 따름. + +### `Tween.Value`는 plain `T`만 받음 — 내부에 별도 반응 경로를 안 둠 + +처음엔 `Tween.Value`도 `T | State`를 받아야 하나(내부에 자체 Observer를 +걸어 값이 바뀔 때마다 트윈을 재시작) 검토했으나 **불필요로 확정** — 이미 +바깥 `:Compute`가 소스 State 변경마다 새 `Tween{Value=v,...}` 테이블을 +통째로 재생성해 StoreBind 재귀 재-dispatch 경로를 타므로, `Tween` 값 +내부에 또 다른 반응 경로를 만들 이유가 없음. "같은 일 하는 두 번째 경로를 +만들지 않는다"는 이 프로젝트가 Effect의 deps/Ref의 대기 경로 등에서 이미 +여러 번 적용한 원칙과 정확히 같은 결. **`Tween = {Value: T, ease..., +onOverride?...}`로 확정** — `Value` 필드는 항상 plain `T`. + +### 3-상태 저장 — `RobloxTween | true | nil` (릴레이션 슬롯 하나로 `hasBeenSet` 통합) + +처음엔 "첫 세팅 여부(`hasBeenSet: boolean`)"와 "실행 중인 엔진 Tween +객체"를 별도 필드로 저장하려 했으나, **하나의 릴레이션 슬롯으로 통합** — +`relate:GetStrong(inst,k)`가 돌려주는 값의 3가지 상태: + +- **`nil`** — 이 `(inst,k)`가 이번 `inst`에서 한 번도 process된 적 없음 + (첫 세팅). +- **`true`** — 최소 한 번 세팅된 적 있음(직전 값이 plain이었든 `Tween` + 였든 무관), 지금은 활성 엔진 Tween 없음. +- **실제 엔진 `TweenBase` 인스턴스** — 지금 애니메이션이 진행 중, 새 값을 + 처리하기 전에 먼저 정리해야 함. + +**분기**: + +1. **`prev == nil`(첫 세팅)** — `realv`가 `Tween`든 plain이든 무관하게 + **애니메이션 없이 즉시 `Value`(또는 plain 값)로 세팅**, 슬롯엔 `true` + 저장. 엔진 기본값(예: Frame 기본 `Position`)에서 목표값으로 날아오는 + "첫 마운트 진입 애니메이션" 버그를 이걸로 방지. +2. **`prev == true`(세팅된 적 있음, 활성 트윈 없음)**: + - `realv`가 plain 값 → 즉시 세팅, 슬롯은 `true` 유지. + - `realv`가 `Tween` → 이제 정상적으로 애니메이션 시작(현재 인스턴스 + 프로퍼티 값에서 자연스럽게 출발), 슬롯에 새로 만든 엔진 Tween 객체 + 저장. +3. **`prev`가 엔진 Tween 객체(활성 트윈 있음)**: + - **먼저 override 정책(기본 Cancel, 아래 절)에 따라 이전 트윈을 정리 — + 반드시 그 정리가 끝난 뒤에 새 값을 세팅한다.** 순서가 뒤바뀌면 + 이전 트윈의 다음 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸 + 위험이 있음(엔진 트윈은 비동기로 계속 프로퍼티를 갱신 중이므로). + - 정리 후: `realv`가 plain 값이면 (정리 결과로 프로퍼티에 남은 현재 + 값 위에) 즉시 덮어쓰기 + 슬롯 `true`. `realv`가 `Tween`면 (같은 + 현재 값에서) 새 트윈 시작 + 슬롯을 새 엔진 Tween 객체로 갱신. + - plain 값이 들어와 진행 중인 트윈을 끝내는 경우, 기존 override + 정책의 4가지 옵션(Cancel/Override/Delete-restart/Move-to-end-restart, + 아래 절)은 원래 Tween→Tween 전환을 염두에 둔 것이라 Tween→plain + 전환에는 사실상 전부 "멈추고 그 자리에서 즉시 덮어쓴다"로 수렴하는 + 것으로 보임 — 별도 5번째 옵션이 필요해 보이진 않으나 **확정은 아님, + M11 착수 시 재확인**. + +**GC-안전성은 기존과 동일** — `Relate`가 `inst`로 weak-keyed되어 있어 +`inst`가 죽으면 이 슬롯(엔진 Tween 객체 포함)도 별도 정리 로직 없이 같이 +GC됨. `retract`는 이 케이스에서 거의 안 불림 — 아래 절 참고. + +### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화 + +기존 모델에선 "Tween 핸들러가 매치되어 애니메이션이 실행 중이었는데, +다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 +핸들러 *타입*이 바뀌는" 경우가 `base/bind-system-plan.md`가 서술하는 +"`retract`가 실제로 의미를 갖는 유일한 패턴"의 대표 예시였음. 새 모델에선 +**매치되는 Dispatch 핸들러가 항상 PropertyHandler 하나뿐**(Tween 여부는 +값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — 이 시나리오 자체가 +Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 3-상태 저장 로직으로 +PropertyHandler 내부에서 처리 — Tag가 이미 하고 있는 "diff는 `process` +자신이 담당" 패턴과 같은 모양이라 새 개념 아님. (PropertyHandler의 +`retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은 +예외 없는 일반 규칙 — 다만 실제로 호출될 일이 이 경로에선 사실상 없음.) + +### 타입 대수: `T' = T | Tween` — Modifier/State/Source에 새 타입 기계 불필요 + +지금 프로퍼티류 필드가 열려 있는 자리(Modifier setter, Ref, Store/Source +필드)는 전부 `T | State` 모양 하나로 통일돼 있음. 여기서 "이 필드의 +`T`" 자체를 `T' = T | Tween`로 치환하면 자동으로 `T | Tween | +State>`가 나옴 — Modifier/State/Source/StoreBind 코드엔 +`Tween` 인지 로직을 전혀 안 넣어도 됨(StoreBind는 원래도 페이로드 타입에 +무관하게 `isState`만 보고 언랩하는 opaque한 구조였음). `Tween`를 실제로 +해석하는 코드는 여전히 PropertyHandler 하나에만 존재. + +**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween`는 `Ref`/`Observer`/ +`Slot`류처럼 `process`/`retract`를 가진 dispatch 참가자가 아니라 `None`/ +`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/ +`State`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에 +안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch +참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래 +"패키지 경계" 절 참고). + +## `useTween`(트윈 우회) — 해소됨, 새 옵션 필드 불필요 + +이전엔 `Tween{useTween=state}`처럼 `Tween` 생성자 안에 별도 +옵션 필드를 두는 방향으로 열려 있었으나, 값-레벨 래퍼 모델에선 **이미 +있는 `state:Apply(factory)`/`:Compute`만으로 공짜로 풀림** — 새 필드 +불필요: + +```lua +-- reduceMotion: State +Position = mySource:Apply(Animate(reduceMotion, {ease = ...})) +``` + +`Animate(reduceMotion, opts)`는 커링 팩토리로, 개념상 다음과 같은 모양: + +```lua +return function(state) + return state:Compute(function(v) + if reduceMotion:Get() then + return v + else + return Tween{Value = v, ease = opts.ease} + end + end) +end +``` + +`reduceMotion`이 바뀌면 `:Compute`가 재계산되어 StoreBind가 자연히 새 +`realv`(plain 또는 Tween-wrapped)로 재-dispatch — PropertyHandler는 평소처럼 +그 값만 보고 처리하면 됨, 우회 로직을 따로 알 필요 없음. **`Animate`는 +base 프리미티브가 아니라 quad-roblox가 제공하는 자유 함수 조합기**(아래 +"패키지 경계" 절) — `Modifier:Apply(Boldify(10))` 커링 패턴과 완전히 +같은 모양이라 base에 새로 추가할 게 없음. + +## 초기 진입 애니메이션(`initValue`) — 여전히 별개 문제, 위 hasBeenSet과 상충 방향 주의 + +`initValue`는 여전히 미확정(2026-08-09 세션 결론 유지: "필요성 낮은 +쪽으로 기움", 완전 폐기는 아님). 다만 이번 세션에서 **"3-상태 저장"의 +1번 분기(`hasBeenSet`)가 "첫 세팅은 무조건 애니메이션 없이 스냅"을 +기본 동작으로 확정**했으므로, 나중에 `initValue`(다이얼로그가 아래에서 +위로 슬라이드-인하는 것처럼 첫 마운트에도 애니메이션을 원하는 경우)가 +실제로 필요해지면 **이 억제 동작을 어떻게 명시적으로 우회할지**(예: +릴레이션 슬롯에 `nil` 대신 다른 초기 상태를 미리 심어두는 옵션)까지 +같이 설계해야 함 — 지금은 새 결정 없이 이 긴장 관계만 기록해둠. + +## `Animate` 콤비네이터 — quad-roblox 유틸(base 아님) + +`Animate(condOrOpts, opts?)`류 팩토리를 quad-roblox가 제공, `:Apply`로 +체이닝해서 쓰는 용도. 상세 시그니처는 미확정(예: `Animate({ease=..., +useAnimate=state})`처럼 조건과 옵션을 하나의 테이블로 합치는 +안도 검토 가치 있음 — 확정 아님, M11에서 정리). 핵심은 **base +프리미티브가 아니라는 것** — `Tween` 값 타입/`isTween`만 +base(`quad-base/Tween.luau`)에 있고, `Animate`는 이미 있는 `:Apply`/ +`:Compute`/`Tween{...}`를 조합한 quad-roblox 레벨 편의 함수라 나중에 +이름/모양을 자유롭게 바꿔도 base 계약에 영향이 없음 — 저비용 +고효율(사용자 표현) 엔지니어링으로 판단. + +## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정) + +- **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween` + predicate/`TweenTag` Brand)만. 엔진 무관. +- **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에 + `isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) + + `Animate.luau`(편의 콤비네이터, 신규). +- **기존 `Handlers/Tween.luau`(독립 Dispatch 핸들러 파일) 자체는 더 + 이상 필요 없음** — `base/architecture.md` 소스트리 갱신 완료. + +## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 — Tween↔Tween 전환에서는 그대로 유지 + +**이 절의 4가지 옵션은 안 바뀜 — 다만 "Dispatch의 `retract` 호출"이 아니라 +"PropertyHandler 내부 로직이 참고하는 정책"으로 위치만 이동했다는 점에 +유의.** 이전 트윈을 취소하고 새 트윈을 만드는 게 맞지만, "취소" 시점의 +동작이 여러 갈래로 갈릴 수 있음: 1. 키 밸류를 받으면, 그로 인해 생성된 트윈을 얻어서 **멈춰버리기**. 2. 트윈 뒤에 **삭제하지 않고 오버라이드**(새 트윈이 이전 트윈의 현재 값에서 @@ -66,43 +237,11 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 동작과 일치. 이 기본값 외 나머지 세 동작(오버라이드/삭제 후 재시작/끝점 이동 후 재시작)은 -라이브러리가 강제하지 않고, `[Tween(key, tweenData, {onOverride=...})]`처럼 -키 설정으로 사용자가 고를 수 있게 열어둠 — `retract(inst, k, v)`가 이전 -값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에 -생성한 실제 Tween 객체"는 `base/relate-plan.md`가 정의하는 `Relate`(`inst`를 -weak 키로 하는 범용 릴레이션 프리미티브, 옛 가칭 `base.perInstanceState`를 -대체)에 담아두면 됨. **GC 확인(2026-08-07 여섯 번째 세션, 사용자 제안 -검증)**: 이 저장소는 -`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된 -구조라, `inst`가 죽으면 그 안에 담긴 Tween 인스턴스 릴레이션도 별도 -정리 로직 없이 같이 GC됨 — `base/bind-system-plan.md`의 "핸들러 내부 -상태 저장" 절 "왜 GC-안전한가" 참고. +라이브러리가 강제하지 않고, `Tween{Value=..., ease=..., onOverride=...}`처럼 +`Tween` 생성 시 넘긴 옵션으로 사용자가 고를 수 있게 열어둠 — PropertyHandler가 +위 3-상태 저장의 3번 분기에서 이 옵션을 참고해 구현. -## `retract`는 완전 소멸(Destroy) 시엔 호출되지 않는다 - -`base/lifecycle-pattern.md`의 핵심 원칙: quad는 자신이 만든 Instance를 생명주기 -끝까지 그대로 들고 있는 소유자라, Destroy 이후에 실행해야 할 정리 로직이 없다 -— 오히려 Destroy된 대상에 `:Cancel()`/`:Stop()` 같은 메서드를 호출하면 에러남 -(대상이 죽으면 그 대상에 묶인 Tween도 함께 죽은 상태가 되므로). 따라서 -**`retract`는 "같은 key에 새 값이 들어와 이전 트윈을 갈아치울 때"만 호출되고, -Destroy 시점엔 아무 것도 안 함(라이프타임 `Connected` 체크로 처리 자체를 -멈추는 것만으로 충분).** - -**메모 — `retract`와 `canExecute`는 서로 다른 문제를 다룬다, 나중에 quadnomicon -급에서 제대로 설명 필요.** "그럼 값 교체가 아니라 값을 계속 관측하는 쪽 -(예: `state:Observer(fn)`으로 Tween을 건 경우)은 Destroy 시 어떻게 -정리되는가?"라는 질문이 자연스럽게 따라오는데, 이건 `retract`의 영역이 -아니라 `canExecute`(라이프타임 predicate, `base/lifecycle-pattern.md`의 -"생명 바인드 유틸" 절)의 영역이다 — Destroy되면 `retract` 호출 없이 그냥 -`canExecute`가 false가 되어 이후 처리 시도 자체가 조용히 no-op된다. -store-bind 일반(Tween 포함)도 같은 결이라 실제로는 이미 일관되게 명시돼 -있지만(`base/bind-system-plan.md` "확정된 디스패치 모델" 절), "왜 이 -경로엔 retract를 쓰고 저 경로엔 canExecute를 쓰는가"라는 내부 구조상의 -이유는 quadnomicon 콘텐츠로 풀어서 설명할 필요가 있음(`research/ -documentation-content-map.md` 심화 콘텐츠 후보에 메모) — 지금은 이 메모만 -남겨두고 상세 설명은 나중 문서화 단계로 미룸. - -## 트윈 옵션 값 모양 — TweenInfo 그대로 vs 편의 필드 (신규, 열린 논의) +## 트윈 옵션 값 모양 — TweenInfo 그대로 vs 편의 필드 (여전히 열린 논의) **아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** Roblox의 `TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, @@ -114,66 +253,37 @@ delayTime)`는 순수 포지셔널 생성자인데, Luau엔 named call 문법이 재사용하고 싶은 경우엔 상관없지만, 대부분의 흔한 케이스(길이/이징만 바꾸고 싶음)에서 매번 포지셔널 생성자를 마주해야 함. 2. **편의 필드로 개별 인자를 받고 기본값을 제공** — 예: - `{Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼 + `Tween{Value=..., Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼 이름 붙은 키로 받고 흔한 기본값(예: `Time=0.2`, `Style=Quad`, `Direction=Out`)을 채워줌. 명시적으로 `TweenInfo`가 이미 있어서 재사용하고 싶다는 케이스에도 열어두면(예: `Info = someTweenInfo` 필드로), 둘 다 지원 가능. **현재 소견(확정 아님)**: 2번(편의 필드 + 기본값)이 흔한 사용 경험상 더 -낫다는 쪽으로 기움 — 대부분의 호출에서 named call이 없는 `TweenInfo.new`의 -가독성 문제를 피할 수 있고, 기본값 덕에 짧은 호출도 가능해짐. 다만 -구체적인 필드 이름/기본값/`TweenInfo` 재사용 경로의 정확한 문법은 아직 -확정 아님 — 나중 논의 대상으로 남김. +낫다는 쪽으로 기움 — 이번 세션의 모든 예시(`Tween{Value=..., ease=...}`)도 +자연스럽게 이 방향을 가정하고 있음. 다만 구체적인 필드 이름/기본값/ +`TweenInfo` 재사용 경로의 정확한 문법은 아직 확정 아님 — 나중 논의 대상으로 +남김. -## 초기 진입 애니메이션(`initValue`)과 트윈 우회(`useTween`) — 둘 다 미확정 (2026-08-09 세션) +## 네임스페이스드 객체 (더 이상 유효한 관심사 아님) -**아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** 사용자가 두 -실사용 시나리오에서 출발한 제안: - -1. **`Tween{initValue=...}`(검토 후 필요성 낮은 쪽으로 기움)** — 동기: - 다이얼로그가 아래에서 위로 올라오며 열리는 것처럼, 처음 마운트될 때 - 시작 위치에서 목표값으로 자연스럽게 움직이는 "enter" 애니메이션을 - 내고 싶은데, 지금 구조로는 첫 마운트 시 이미 목표값이 프로퍼티에 - 세팅된 채로 시작해 트윈이 걸리지 않음. `initValue`로 먼저 그 값을 - 세팅해두고 그 다음 실제 목표값으로 트윈을 트리거하면 될 것 같다는 - 아이디어. **사용자가 스스로 재검토하며 "필요 없는 것 같다"는 쪽으로 - 기움** — 정확한 반례(재process/재마운트 시 `initValue`를 매번 다시 - 적용하면 안 되는데 "최초 1회"를 무엇으로 판별할지가 또 다른 설계 - 문제가 될 수 있음)는 다음 세션에서 확인 필요, 완전 폐기는 아직 아님. -2. **`Tween{useTween = state}`(필요성은 확인, 모양은 미정)** — - 동기: `Visible`로 여닫는 다이얼로그가 항상 아래에서 위로 슬라이드 - 올라와야 하는 게 기본이지만, 특정 상황(접근성 설정으로 애니메이션을 - 완전히 끄고 싶은 경우 등)엔 트윈을 건너뛰고 즉시 목표값으로 스냅해야 - 함 — 트윈을 무시하고 값을 그냥 세팅할 방법이 필요하다는 문제의식. - 제안 동작: 트윈이 실행되려는 시점에 먼저 `useTween:Get()`을 읽어서 - `false`면 진행 중이던 트윈을 전부 죽이고 목표값으로 즉시 `Set`, - `true`(또는 생략 시 기본값)면 평소대로 트윈. - -**결론(미확정)**: `initValue`는 필요 없어 보이지만(사용자 소견) 완전 -폐기는 아직 안 함. `useTween`류 트윈 우회 필요성은 실사용 근거가 -뚜렷해 유지될 가능성이 높지만, **정확한 옵션 키 모양과 문서화 방식이 -아직 전혀 안 정해짐**(사용자 강조: "어떤 모양을 주냐가, 문서화 어떤 -식으로 하냐가 엄청 중요한듯") — 위 "트윈 옵션 값 모양" 절의 편의 필드 -논의와 함께 M11 착수 전 나중 세션에서 마저 정리. - -## 네임스페이스드 객체 (성능상 이유로 보류) - -트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 — -CollectionService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페이스가 따로 있을 -필요가 있는지는 미정(단, 위 정정으로 이 절 자체의 필요성이 낮아짐 — 핸들러가 -이미 대상을 직접 받으므로 "나중에 이름으로 찾아서 트윈"할 필요 자체가 잘 -없을 수 있음). +기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고 +열어뒀던 절 — 새 모델에서는 PropertyHandler가 `inst`를 항상 직접 +받으므로(다른 모든 핸들러와 동일) 이 문제 자체가 성립하지 않음. 절 자체는 +과거 기록으로만 남김, 실행할 내용 없음. ## 열린 질문 (`.claude/question.md`에도 취합) - 기본값(Cancel)은 확정됨. 남은 건 나머지 세 동작(오버라이드/삭제 후 재시작/ - 끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처 — 구현 - 단계에서 확정. -- 트윈 옵션 값 모양(위 "트윈 옵션 값 모양" 절, 신규) — `TweenInfo` 그대로 - 받을지 편의 필드+기본값으로 받을지, 소견은 후자 쪽이지만 확정 아님. -- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부 — - `research/pre-implementation-audit.md` 2-10번 참고, M11 착수 시 확정. -- `initValue`(진입 애니메이션)/`useTween`(트윈 우회) — 위 절 참고, - 후자는 필요성은 확인됐으나 모양/문서화 미정, 전자는 필요성 자체가 - 낮은 쪽으로 기움. + 끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처, 그리고 + Tween→plain 전환에 5번째 옵션이 필요한지 — 구현 단계에서 확정. +- 트윈 옵션 값 모양(위 절) — `TweenInfo` 그대로 받을지 편의 필드+기본값으로 + 받을지, 소견은 후자 쪽이지만 확정 아님. +- `Animate` 콤비네이터의 정확한 시그니처(조건/옵션 분리 vs 통합) — M11에서 + 정리. +- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부(3-상태 슬롯을 + `true`로 되돌리는 시점) — `research/pre-implementation-audit.md` 2-10번 + 참고, M11 착수 시 확정. +- `initValue`(진입 애니메이션) — 위 절 참고, 필요성 자체가 낮은 쪽으로 + 기움, 완전 폐기는 아님. 필요해지면 hasBeenSet 억제 동작과의 상충을 + 같이 풀어야 함. diff --git a/CLAUDE.md b/CLAUDE.md index d91e129..06d55ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2542,3 +2542,117 @@ Instance에 직접 `.Parent =` 대입으로 자식을 넣거나 빼면 `lengthLi **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 우선) — 이번 세션은 순수 문서 갭 보강이라 우선순위엔 영향 없음. + +## 2026-08-10 두 번째 세션 — Tween 구조 전면 재설계: 독립 Dispatch 핸들러 → +값-레벨 `Tween` 래퍼, `pre-implementation-audit.md` 1-1 완전 해소 + +사용자가 "트윈도 타입 문제가 있다 — 키 타입을 어떻게 하냐, Property +setter가 더 분발해서 `V`가 `isTween`이면 트윈 넣는 게 낫지 않냐"고 +제기하며 시작된 긴 단일 스레드. 기존 확정 모델(`[Tween(key, +tweenData...)] = storeValue`, `v`가 Store인 아무 `k`나 잡는 우선순위 +최상위 Dispatch 핸들러, 2026-08-04부터 확정)이 실은 +`research/pre-implementation-audit.md` 우선순위1-1이 이미 지적해뒀던 +구조적 모호함("애니메이션 없는 일반 반응형 프로퍼티 바인딩도 결국 +이름이 Tween인 파일을 거쳐가는가")을 안고 있었다는 걸 사용자 제안이 +정확히 겨냥한 것으로 드러나, 세션 내내 살을 붙여 완전히 재설계까지 +감. 구 모델은 `archive/tween-special-bind-key-reversed.md`로 이전, +`research/tween-plan.md`는 전면 재작성됨 — 상세 근거는 그 두 문서가 +최종 소스, 여기는 결정 흐름만 요약. + +**핵심 재설계**: State/Source 언랩(범용 `Dispatch/StoreBind.luau`, `k`/`v` +타입 무관)과 "이 값이 트윈 대상인가" 판단을 완전히 분리 — 후자는 별도 +Dispatch 핸들러/우선순위 경쟁이 아니라, **PropertyHandler가 `realv`를 +다 풀어낸 뒤 직접 하는 값-레벨 분기**(`isTween(realv)`)로 옮김. `Tween(opts: +{Value: T, ease...}) -> Tween`는 `Store({...})`와 같은 `Type(args)` +테이블 팩토리. 이 전환 하나로 우선순위1-1이 구조적으로 성립 불가능해짐 +— 범용 반응형 바인딩과 Tween이 애초에 같은 핸들러를 놓고 경쟁할 지점 +자체가 없어짐. + +**세션 중 순서대로 다듬어진 세부 결정들**(전부 최종적으로 `research/ +tween-plan.md`에 반영): + +1. **`Tween.Value`는 plain `T`만, 자체 반응 경로 없음** — 처음엔 `Value`도 + `T|State`를 받아 내부에 별도 Observer를 걸어야 하나 검토했으나, + 바깥 `:Compute`가 소스 변경마다 `Tween{Value=v,...}`를 통째로 재생성해 + StoreBind 재귀를 타므로 불필요함을 확인 — "같은 일 하는 두 번째 경로를 + 안 만든다" 원칙 재적용, `Tween = {Value: T, ease...}`로 확정. +2. **3-상태 릴레이션 슬롯으로 `hasBeenSet`과 활성 엔진 트윈 저장을 통합** — + `relate:GetStrong(inst,k)`가 `RobloxTween | true | nil` 중 하나: + `nil`=이 키 첫 세팅(애니메이션 없이 즉시 스냅, 기본값→목표값으로 + 날아오는 진입 애니메이션 버그 방지), `true`=세팅된 적 있음/활성 트윈 + 없음(정상 애니메이션 시작 가능), 엔진 객체=활성 트윈 있음(override + 정책대로 정리 먼저). 사용자가 직접 "hasBeenSet은 어차피 트윈에만 + 쓰이니 트윈 저장 슬롯 하나로 합치자"고 제안해 확정. +3. **활성 트윈이 있는데 plain 값이 들어오는 경우의 순서 규칙 신설** — + 먼저 override 정책대로 이전 트윈을 정리(멈추거나 끝냄)하고, **그 + 정리가 끝난 뒤에만** 새 값을 세팅. 순서가 뒤바뀌면 이전 트윈의 다음 + 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸 위험이 있어서 — 사용자가 + 직접 짚은 시퀀싱 버그. +4. **타입 대수: `T' = T | Tween` 치환만으로 해결, 새 타입 기계 불필요** — + 지금 프로퍼티류 필드가 전부 `T | State` 모양으로 통일돼 있는데, + 여기서 "이 필드의 `T`" 자체를 `T' = T | Tween`로 치환하면 자동으로 + `T | Tween | State>`가 나옴 — Modifier/State/Source/ + StoreBind 코드엔 `Tween` 인지 로직이 전혀 안 들어감(StoreBind는 원래도 + 페이로드 타입에 무관하게 `isState`만 봄), 타입 생성 스크립트가 필드 + 타입 문자열만 바꾸면 끝. 사용자가 직접 대수적으로 도출. +5. **`useTween` 우회 — 새 옵션 필드 없이 해소.** 이전엔 + `Tween{useTween=state}`처럼 별도 필드가 필요하다고 열어뒀으나, + 2026-08-07 일곱 번째 세션에 확정된 `state:Apply(factory)` sugar 위에 + `someState:Apply(Animate(reduceMotion, opts))`처럼 조건부로 `Tween{...}`를 + 씌우거나 안 씌우는 `:Compute` 팩토리 하나로 공짜로 풀림 — 새 base + 메커니즘 불필요. +6. **`Animate` 콤비네이터는 quad-roblox 유틸, base 프리미티브 아님** — + `Tween` 값 타입/`isTween`만 base(`quad-base/Tween.luau`)에 있고, + `Animate`는 이미 있는 `:Apply`/`:Compute`/`Tween{...}`를 조합한 편의 + 함수라 나중에 이름/모양을 자유롭게 바꿔도 base 계약에 영향 없음 — + 사용자 표현으로 "저비용 고효율 엔지니어링". +7. **패키지 경계는 Tag가 이미 밟은 분리를 그대로 재사용** — quad-base: + `Tween.luau`(값 타입만). quad-roblox: `Handlers/Property.luau`(isTween + 분기+3-상태 저장+override 정책 흡수, 기존 독립 `Handlers/Tween.luau` + 폐기) + `Animate.luau`(신규). +8. **부수 발견 — `retract`가 Tween 경로에서 사실상 필요 없어짐.** 기존 + 모델에서 "Tween↔프로퍼티 핸들러 타입 교체"가 `retract`가 실제로 + 의미를 갖는 유일한 대표 예시였는데, 새 모델에선 매치되는 Dispatch + 핸들러가 항상 PropertyHandler 하나뿐이라 이 케이스 자체가 사라짐 — + 트윈 취소/전환은 PropertyHandler 내부의 3-상태 슬롯 로직으로 대체(Tag가 + 이미 하는 "diff는 process 자신이 담당" 패턴과 같은 모양). `retract` + 필드 자체는 "생략 불가" 일반 규칙이라 여전히 정의는 해두되, 실제 + 호출은 거의 없어짐. Tag(핸들러 타입이 실제로 바뀌게 재설계되어 + `retract`가 필요해진 사례)와 Tween(핸들러 타입이 안 바뀌게 재설계되어 + `retract` 필요성이 사라진 사례)을 서로 반대 방향 사례로 archive 문서에 + 대비해둠 — quadnomicon 소재. +9. **`Tween`의 핸들러 계층 분류 정정** — `base/modifier-plan.md`가 + 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch 참가자"(State/Source에 + 담겨도 재귀 재-dispatch가 그대로 처리해주는 부류)로 묶어뒀는데, 이제 + `Tween`는 `process`/`retract`가 없는 순수 raw 데이터 값이라 `None`과 + 같은 분류로 정정 — Modifier 필드/`State`가 막는 "핸들러 + 계층 값 → error" 규칙에 안 걸린다는 결론은 안 바뀜(그냥 raw 값이라서로 + 근거가 바뀜). +10. **`initValue`(진입 애니메이션)와 hasBeenSet의 긴장 관계를 기록만 + 해둠** — hasBeenSet이 "첫 세팅은 무조건 스냅"을 기본 동작으로 + 확정했으므로, 나중에 `initValue`(다이얼로그 슬라이드-인 등)가 실제로 + 필요해지면 이 억제 동작을 명시적으로 우회하는 방법까지 같이 설계해야 + 함 — 새 결정 없이 상충 관계만 `research/tween-plan.md`에 남김. + +**여전히 열려있는 것**(M11 착수 시 확정): override 정책 4가지 중 기본값 +Cancel 외 세 옵션의 정확한 키 이름/시그니처, Tween→plain 전환에 5번째 +옵션이 필요한지, 트윈 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드 — 소견은 +후자), `Animate`의 정확한 시그니처(조건/옵션 분리 vs 통합). + +**반영된 파일**: `research/tween-plan.md`(전면 재작성, 최종 소스), +`archive/tween-special-bind-key-reversed.md`(신규, 구 모델 원문+역전 +사유), `base/bind-system-plan.md`(9곳 — "확정된 디스패치 모델"의 대표 +예시를 Tween에서 StoreBind로, `retract` 필요 패턴 예시를 Tag로 교체, +"Dispatch는 프리미티브가 아니다"/"Dispatch 체인" 절의 핸들러 목록에서 +Tween 제거, `None` 센티널 절 예시 갱신, Ref/Brand 절 문구 정정), +`base/architecture.md`(소스트리 — `quad-base/Tween.luau` 신설, +`quad-roblox/Handlers/Tween.luau` 삭제하고 `Handlers/Property.luau` +설명에 흡수, `Animate.luau` 신설), `base/modifier-plan.md`(핸들러 계층 +분류에서 Tween 제외 + 신규 "10. `Tween`와의 타입 합성" 절), +`research/pre-implementation-audit.md`(우선순위1-1 해소 표시), +`ROADMAP.md`(M11 전면 재작성, M2/M7 체크박스 갱신), `.claude/question.md`/ +`.claude/README.md`(참조 동기화). + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 +우선) — 이번 세션도 순수 설계 확정/문서 정리라 M0 착수 우선순위 자체는 +그대로. M11 착수 시점이 오면 위 "여전히 열려있는 것" 목록부터 확인. diff --git a/ROADMAP.md b/ROADMAP.md index db42293..18b693e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,7 +73,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은 `Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서 갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀 - 재디스패치를 쓰는 케이스(Tween store-bind, `NoneHandler`)에서 매 + 재디스패치를 쓰는 케이스(`StoreBind`, `NoneHandler`)에서 매 사이클 불필요한 `retract`가 반복 호출될 위험) - [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ `process`/`retract` — `isHandlable`도 `inst`를 받도록 확정, 2026-08-07 @@ -137,7 +137,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) - [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={handler,handler,...} (strong 순서 배열)}}`) + `Dispatch.retractUnder(inst,k,keep,v)` — - 재귀 재-dispatch(StoreBind/Tween/NoneHandler)의 retract를 다단 + 재귀 재-dispatch(StoreBind/NoneHandler)의 retract를 다단 체인까지 정확히 전파(2026-08-08 세 번째 세션, `base/ bind-system-plan.md` "Dispatch 체인" 절 — `pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소). `Dispatch.process`가 매치될 @@ -282,8 +282,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` (`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 - "이전 매치 핸들러 추적" 항목과 함께 구현 — Tween store-bind 핸들러와 + "이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — 확정 완료 +- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween` 치환 반영(타입 생성 + 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 + 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` + 10번, 2026-08-10 세션, `research/tween-plan.md`) ## M8 — Ref @@ -344,9 +348,28 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M11 — Tween -- [ ] `research/tween-plan.md` 남은 옵션 이름 확정(구조는 이미 확정) -- [ ] `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러, 기본 오버라이드 - Cancel) +**[2026-08-10 세션, 구조 재설계]** 독립 Dispatch 핸들러 모델에서 값-레벨 +`Tween` 래퍼 모델로 전환 — 상세는 `research/tween-plan.md`(전면 +재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. + +- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/ + `TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음) +- [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존 + `Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 + (`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈 + 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 + 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 + 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀) +- [ ] override 정책 4가지(기본 Cancel/Override/Delete-restart/ + Move-to-end-restart) 중 기본값 외 옵션 키 이름/시그니처 확정, + Tween→plain 전환에 5번째 옵션이 필요한지 확인 +- [ ] `research/tween-plan.md` "트윈 옵션 값 모양" 확정(TweenInfo 그대로 + vs 편의 필드+기본값 — 소견은 후자) +- [ ] `quad-roblox/Animate.luau`(편의 콤비네이터 — `:Apply`로 체이닝, + `useTween` 우회는 이걸로 자연히 커버되어 별도 옵션 필드 불필요, + 정확한 시그니처는 M11에서 확정) +- [ ] `initValue`(진입 애니메이션) 필요성 재검토 — 필요해지면 hasBeenSet + 억제 동작과의 상충부터 풀 것(`research/tween-plan.md` 참고) ## 특정 마일스톤에 안 묶이고 병행 가능 From 2be4fffeca4ed4289e8f5c3b3213d7cd279bbbdb Mon Sep 17 00:00:00 2001 From: qwreey Date: Mon, 10 Aug 2026 01:08:02 +0900 Subject: [PATCH 62/62] =?UTF-8?q?decide(bind-system):=20OnChange=20?= =?UTF-8?q?=ED=8A=B9=EC=88=98=20=ED=82=A4=20=EC=8B=A0=EC=84=A4=20-=20GetPr?= =?UTF-8?q?opertyChangedSignal=20=EB=B0=94=EC=9D=B8=EB=94=A9,=20=EC=A0=9C?= =?UTF-8?q?=EB=84=A4=EB=A6=AD=20=EC=97=86=EC=9D=B4=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이벤트 문자열 키 패턴이 GetPropertyChangedSignal엔 안 통해서(프로퍼티 이름이 값 세팅 키와 겹침) 별도 OnChange(name) DI 키를 신설. Attribute와 달리 제네릭 타입 파라미터 없이 콜백 타입은 인라인 명시 - 이벤트 바인딩과 같은 급의 타입 안전성 트레이드오프. 전부 quad-roblox 소속, State은 기존 이벤트 store-bind 메커니즘 재사용. 프로퍼티별 정적 코드 생성 안은 규모 폭발로 기각. --- .claude/README.md | 2 + .../onchange-per-property-codegen-rejected.md | 38 ++++++++++++ .claude/base/architecture.md | 1 + .claude/base/bind-system-plan.md | 10 +++- .claude/base/onchange-plan.md | 60 +++++++++++++++++++ CLAUDE.md | 40 +++++++++++++ ROADMAP.md | 5 +- 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 .claude/archive/onchange-per-property-codegen-rejected.md create mode 100644 .claude/base/onchange-plan.md diff --git a/.claude/README.md b/.claude/README.md index 298fd35..ed33ee4 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -40,6 +40,7 @@ | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. 이제 `retract`가 의미 있음(타입이 바뀌면 전체 삭제, 같은 Tag끼리는 `process`가 diff). 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md` | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute` vs `BooleanAttribute`류)만 미확정 | +| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) @@ -81,6 +82,7 @@ | `keyed-collection-state-method-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두려던 초안 — Source 미사용 컴포넌트가 접근 못 한다는 반례로 철회, 현재는 자유 함수로 확정 | | `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 | | `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween` 래퍼 모델로 완전히 대체됨(`research/tween-plan.md`) | +| `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | ## 참고 diff --git a/.claude/archive/onchange-per-property-codegen-rejected.md b/.claude/archive/onchange-per-property-codegen-rejected.md new file mode 100644 index 0000000..4d55a66 --- /dev/null +++ b/.claude/archive/onchange-per-property-codegen-rejected.md @@ -0,0 +1,38 @@ +# [기각됨] `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 + +**기각 일시**: 2026-08-10. **현재 유효한 설계**: `base/onchange-plan.md` — +`OnChange(name)` 제네릭 없는 단일 팩토리, 콜백 파라미터 타입은 호출부가 +직접 명시. + +## 무엇이었나 + +`Attribute`가 `[Attribute<> "name"]`(제네릭 경로)과 `[BooleanAttribute +"name"]`(자주 쓰는 타입만 정적 지름길)을 둘 다 채택했던 것(`base/ +attribute-plan.md`)과 같은 모양으로, `OnChange`도 `OnChange.Position`/ +`OnChange.Size`처럼 프로퍼티 이름별로 이미 타입이 박힌 정적 필드를 코드 +생성기로 전부 만들어두는 안이 검토됐음. + +## 기각 이유 + +Attribute의 정적 지름길과 겉보기엔 같은 절충처럼 보이지만 실제로는 규모가 +다른 문제: + +- Attribute의 타입 파라미터 `T`는 Roblox Attribute가 지원하는 좁고 고정된 + 프리미티브 집합(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/ + CFrame/Instance 등, ~10종)에서만 옴 — 정적 지름길 후보가 유한하고 작음. +- `OnChange`가 감쌀 수 있는 프로퍼티는 **클래스마다 이름/타입 집합이 전부 + 다름** — `Frame.Position`, `TextLabel.Text`, `ScrollingFrame.CanvasSize` + 등 클래스 종류만큼 프로퍼티 집합이 갈라지므로, "자주 쓰는 것만 정적 + 지름길"이 성립하려면 사실상 (클래스 수 × 프로퍼티 수) 규모의 조합을 + 전부 커버해야 함 — 유한한 지름길 목록으로 수렴하지 않음. +- 지름길을 특정 클래스 몇 개(Frame 등)로만 좁혀도, 그 클래스의 `OnChange` + 네임스페이스가 실제로 그 클래스에서만 유효한 프로퍼티인지 타입 레벨에서 + 강제할 방법이 마땅치 않음 — 결국 반쯤 타입 안전한 것처럼 보이는 인터페이스만 + 남고 실제 검증은 여전히 없음. + +## 대안(채택됨) + +콜백 파라미터 타입을 호출부가 직접 명시하는 것으로 충분 — 이미 이벤트 +바인딩(`Frame { MouseButton1Click = fn }`)이 콜백 시그니처 검증을 포기하는 +것과 같은 급의 트레이드오프를 받아들이는 것뿐, `OnChange`만 유별나게 정적 +타입 안전성을 추구할 근거가 약함. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 982a850..f17e18a 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -159,6 +159,7 @@ quad/ ├── 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 조작) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 9b59486..08dcaec 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -245,7 +245,8 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ - UICornerHandler/TagHandler/AttributeHandler 등)은 팩토리가 `BaseModule`을 + OnChangeHandler/UICornerHandler/TagHandler/AttributeHandler 등)은 + 팩토리가 `BaseModule`을 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 같은 패턴, 새 메커니즘 아님). - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, @@ -2009,6 +2010,13 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가 깨진 건 아님. +**`GetPropertyChangedSignal`은 이 문자열 키 패턴이 안 통함 — 별도 `OnChange` +DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라 +그대로 `Connect`하면 되지만, `GetPropertyChangedSignal(name)`은 프로퍼티 +이름을 인자로 받아야 하고 그 이름이 "값 세팅" 키 네임스페이스와 겹쳐서 +평범한 문자열 키로는 세팅과 리스닝을 구분할 수 없음 — 상세는 +`base/onchange-plan.md`. + **PA님 코드와 대조해서 재확인한 것(변경 없음)**: - **OOP 회피 결정은 오히려 보강됨** — PA님의 `ObjectOrientedProgramming/ class.luau`도 `setmetatable(methods, {__index = parent})` 체이닝 상속이라 diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md new file mode 100644 index 0000000..e673da1 --- /dev/null +++ b/.claude/base/onchange-plan.md @@ -0,0 +1,60 @@ +# `OnChange` 특수 키 — `GetPropertyChangedSignal` 바인딩 + +**상태**: base — 2026-08-10 세션에서 확정. quad-roblox 전용(값 타입/API +레이어 없음, `Attribute`와 같은 패키지 배치). + +## 문제 + +이벤트 바인딩은 이미 평범한 문자열 키 + reflection(`GetEventsOfClass`)으로 +확정돼 있음(`bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" +절) — `inst[key]`가 이미 `RBXScriptSignal`이라 그냥 `Connect`하면 됨. +`GetPropertyChangedSignal(name)`은 이 패턴이 그대로 안 통함: 프로퍼티 이름을 +인자로 받아 **별도 메소드 호출**로 시그널을 얻어야 하고, 그 프로퍼티 이름은 +이미 "값 세팅" 키 네임스페이스(`Frame.Position = x`)와 겹침 — 값 타입만으론 +"세팅"과 "변경 리스닝"을 구분할 방법이 없어서 별도 마커가 필요함. + +## 확정 + +- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 DI 키 + 팩토리, `Attribute(name)`/`Tag(...)`와 같은 패턴. 사용 예: + `Frame { [OnChange "Position"] = function(v: UDim2) ... end }`. +- **제네릭 타입 파라미터 없음 — `OnChange<>` 같은 타입 파라미터화는 안 + 함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 + (`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과 + 일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를 + Luau가 검증 못 하는 대가를 받아들인다"는 결정(`bind-system-plan.md` "이벤트 + 바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도 + 포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려 + `Attribute<>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더 + 엄격한 걸 요구하는 셈이라 일관성이 깨짐. +- **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**: + `archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의 + "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 + 규모가 다른 문제라 기각. +- **패키지 경계: 전부 quad-roblox** — `Handlers/OnChange.luau`에 `OnChange(name)` + 키 팩토리와 Handler를 같이 둠(`Attribute.luau`와 같은 배치, base 쪽 값 + 타입 파일 없음). `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 + 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 + 다름. +- **`process(inst,k,v)`**: `inst:GetPropertyChangedSignal(name):Connect(function() + v(inst[name]) end)`. **`retract(inst,k,v)`**: 그 Connection을 + `:Disconnect()`. 일반 `Handlers/Event.luau`와 같은 결(Connection + 관리뿐, 새 메커니즘 없음). +- **`State` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도 + store-bind 가능 — `false`로 disconnect" 메커니즘(`bind-system-plan.md`)이 + `OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`/`retract`만 + 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서 + 언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기 + 불필요. + +## 다른 특수 DI 키와의 대조 + +| | 소스 | 값 타입 | 패키지 경계 | +|---|---|---|---| +| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) | +| `Attribute(name)` | `SetAttribute`/`GetAttribute` | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | quad-roblox(`Handlers/Attribute.luau`) | +| `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) | + +`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 +성질이 Attribute(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가 +헷갈리지 않도록 표로 명확히 구분해둠. diff --git a/CLAUDE.md b/CLAUDE.md index 06d55ac..6a87033 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2656,3 +2656,43 @@ Tween 제거, `None` 센티널 절 예시 갱신, Ref/Brand 절 문구 정정), **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 우선) — 이번 세션도 순수 설계 확정/문서 정리라 M0 착수 우선순위 자체는 그대로. M11 착수 시점이 오면 위 "여전히 열려있는 것" 목록부터 확인. + +## 2026-08-10 세 번째 세션 — `OnChange` 특수 키 신설: `GetPropertyChangedSignal` +바인딩, 제네릭 없이 확정 + +사용자가 `GetPropertyChangedSignal`을 어떻게 다뤄야 할지 물으며 시작 — +이벤트는 이미 평범한 문자열 키(`inst[key]`가 곧 Signal)로 확정돼 있는데, +`GetPropertyChangedSignal(name)`은 프로퍼티 이름을 인자로 받아야 하고 그 +이름이 "값 세팅" 키 네임스페이스와 겹쳐서 같은 패턴을 못 씀 — 사용자가 +`[OnChange "PropertyName"] = function(v) ... end` 형태(타입은 콜백에 직접 +명시)와 "`OnChange.PropertyName`을 전부 코드 생성"하는 대안 두 가지를 +제시하며 의견을 물음. + +**확정**: `OnChange(name)` DI 키 팩토리, **제네릭 타입 파라미터 없음** — +`Attribute<>`와 달리 콜백 파라미터 타입은 호출부가 직접 명시. 이미 +확정된 "이벤트 바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 +받아들인다"는 결정과 같은 급의 트레이드오프라 새로 정당화할 것 없다는 게 +근거 — 오히려 `Attribute`처럼 제네릭으로 정확히 맞추려 들면 이벤트 키보다 +더 엄격한 걸 요구하는 셈이라 일관성이 깨짐. 프로퍼티별 정적 코드 생성 안은 +기각(`archive/onchange-per-property-codegen-rejected.md`) — Attribute의 +정적 지름길은 타입 파라미터가 좁고 고정된 프리미티브 집합(~10종)에서만 +와서 지름길 후보가 유한한데, 프로퍼티는 클래스마다 이름/타입 집합이 전부 +달라 (클래스 수 × 프로퍼티 수) 규모로 폭발함 — 겉보기엔 비슷한 절충 +같지만 실제로는 규모가 다른 문제. + +패키지 경계는 **전부 quad-roblox**(`Handlers/OnChange.luau`, `Attribute`와 +같은 배치 — `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 둘 +값 타입/API 레이어가 없음). `process`는 `GetPropertyChangedSignal(name):Connect`, +`retract`는 `:Disconnect` — 일반 `Handlers/Event.luau`와 같은 결. **`State` +지원도 새 메커니즘 없이 해소** — 이미 확정된 "이벤트도 store-bind 가능 +(`false`로 disconnect)" 메커니즘이 그대로 적용됨, `OnChangeHandler`는 +`process`/`retract`만 구현하면 범용 `Dispatch/StoreBind.luau`가 State/Source +언랩+재귀 재-dispatch를 알아서 해줌. + +`base/onchange-plan.md`(신규)/`base/bind-system-plan.md`(이벤트 네이밍 절 +교차 참조)/`base/architecture.md`(소스트리 `Handlers/OnChange.luau`)/ +`ROADMAP.md`(M10 제목·체크박스)/`.claude/README.md`(base/archive 인덱스) +전부 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 +우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index 18b693e..850619f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -331,9 +331,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `props.Modifier`/`props.Ref` 전달 관례를 정식 컴포넌트로 검증(M0 스파이크를 정식화) -## M10 — Event / Attribute / Tag +## 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(...)`/