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`의 + 관련 항목은 해소됨으로 갱신.