fix(base): 중간검토(질문 모드)에서 발견된 설계 결함 다수 수정

.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj
This commit is contained in:
qwreey 2026-08-09 23:37:46 +09:00
parent 5836c2d12a
commit f198fd9c6b
Signed by: qwreey
GPG key ID: D28DB79297A214BD
14 changed files with 645 additions and 155 deletions

View file

@ -30,7 +30,7 @@
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/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차 라운드) | | `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 구현 책임 분리 — 확정 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`, `keyFn` 생략 시 index 기본값, `updateFn<UD>(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn``userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가 | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`, `keyFn` 생략 시 index 기본값, `updateFn<UD>(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn``userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설 |
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) | | `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` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `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` 포인터로 압축 | | `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의 결정 자체가 아니라 다른 문서가 인용하는 온디맨드 자료라 항상 읽을 필요는 없음 | | `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-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/` — 아직 착수 전, 상의 필요 ## `research/` — 아직 착수 전, 상의 필요

View file

@ -1,7 +1,8 @@
# Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기 # Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기
**상태**: base(메커니즘/`None`/`retract` 동작은 확정) — 타입 파라미터화 **상태**: base — 메커니즘/`None`/`retract` 동작뿐 아니라 타입 파라미터화도
이름만 미확정. `[Attribute "Name"]` DI 키의 존재 자체는 `architecture.md` **둘 다 채택으로 확정**(2026-08-09 열한 번째 세션, 아래 참고). `[Attribute
"Name"]` DI 키의 존재 자체는 `architecture.md`
4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸
2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute 2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute
에도 적용해야 한다는 사용자 지적) — `bind-system-plan.md`의 "Attribute 에도 적용해야 한다는 사용자 지적) — `bind-system-plan.md`의 "Attribute
@ -23,23 +24,28 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
"Value 오브젝트 기각, Attribute로 확정" 결정과 같은 방향 — Instance 타입 "Value 오브젝트 기각, Attribute로 확정" 결정과 같은 방향 — Instance 타입
지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐). 지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐).
**후보 두 가지 (미확정)**: **확정(2026-08-09 열한 번째 세션) — 둘 다 채택**:
- `[Attribute<<boolean>> "name"] = true` (리터럴 또는 store-bind 값) — - `[Attribute<<boolean>> "name"] = true` (리터럴 또는 store-bind 값) —
제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 기본/범용 경로.
- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 - `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자
패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/
`InstanceAttribute` 등). `InstanceAttribute` 등). 실사용 빈도가 높은 몇 개만 지름길로.
**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 **근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스
패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론
구조적으로 똑같은 문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 재사용 — `new<ClassName>(className)` 제네릭 생성자 + 자주 쓰는 ~25개는
필드로 나눌지" 고민이 있었고, 결론은 **둘 다**(`new<ClassName>(className)` 정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히
제네릭 생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히
같은 모양을 재사용하면 자연스러울 가능성 — `Attribute<T>("name")` 제네릭을 호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느
기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ 쪽을 쓰든 런타임 동작에 차이 없음.
`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/
`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 사용자 확인 전 **[실측 필요, M0/M10]** `[Attribute<<boolean>> "name"] = value`처럼 DI
소견일 뿐 — `.claude/question.md`에 반영, 사용자 판단 필요. 키 제네릭 파라미터로 `=``value`의 타입까지 실제로 좁혀지는지는
미검증 — Luau 솔버가 이 조합을 못 풀면 `value``any`로 남을 수 있음.
단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적
타입체크 실패일 뿐, `SetAttribute` 호출 자체는 항상 정상 작동) — 안
되면 `BooleanAttribute` 같은 정적 타입 패밀리 쪽이 사실상 유일하게
믿을 수 있는 정적 체크 경로가 됨.
## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) ## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션)

View file

@ -365,6 +365,15 @@ function Dispatch.retractUnder(inst, k, keep, v)
end end
``` ```
- **`handler.process(inst,k,v)``Dispatch.process`를 거치지 않고 직접
호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.**
이유: `chains` 배열에 push하는 bookkeeping이 `Dispatch.process` 내부에만
있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 활성화됐는데도
체인에 안 올라가 — 나중에 다른 값으로 바뀌어도 `retractUnder`가 이
핸들러의 존재를 몰라 `retract`가 영영 안 불리거나(리소스 누수), 반대로
체인 순서 자체가 실제 활성 상태와 어긋나는 정합성 붕괴로 이어짐. 재귀/
래핑 핸들러가 위임할 때도 항상 `Dispatch.process(inst,k,newV)`
불러야지 매치된 핸들러의 `.process`를 스스로 찾아 직접 호출하면 안 됨.
- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst, - **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst,
k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)` k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)`
부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신) 부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신)
@ -450,8 +459,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
(`localIndex:With(offset):Compute(function(i,o) return i+o end)`을 (`localIndex:With(offset):Compute(function(i,o) return i+o end)`을
`LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind `LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind
재실행 메커니즘이 알아서 다시 씀 — 새 push/observer 시스템 불필요). 재실행 메커니즘이 알아서 다시 씀 — 새 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 **둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB
(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — (2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 —
@ -467,12 +481,15 @@ lazy 생성.
원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) 원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙)
`nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 `nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이
구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) 구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림)
`None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, `Ref` `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, PreRef
콜백/대기자 배열·PreRef pre-pass에 이미 적용된 것과 같은 원칙(위 "왜 pre-pass 소진 슬롯에 이미 적용된 것과 같은 원칙(위 "PreRef" 절의
`nil`이 아니라 `None`인가" 절 참고). 다만 `recompute``1..N` 고정 "왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 최종적으로 `nil`
범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 자체는 안 되돌아간 건 Ref 콜백/대기자 배열 한정**이고 `sourceList`/PreRef
생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 여부 pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야
구별과 접근 비용" 쪽. 하는 배열은 여전히 `None`이 맞음, 헷갈리지 말 것). 다만 `recompute`
`1..N` 고정 범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제
자체는 안 생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐
여부 구별과 접근 비용" 쪽.
**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: **recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**:
@ -722,8 +739,19 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플
존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우
채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상
필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용
가능(발화 후 해당 인덱스만 **`None`으로 소진** — 아래 구현 디테일의 가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고,
2026-08-07 열 번째 세션 정정 참고, 단순 `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 여섯 번째 세션, 사용자 - **`:Wait(thread?)``thread` 인자(2026-08-07 여섯 번째 세션, 사용자
제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인
코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()` 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`
@ -737,38 +765,45 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플
블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread` 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`
`nil`이면 yield, 있으면 yield 안 함. `nil`이면 yield, 있으면 yield 안 함.
- **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume
payload 정정, 열 번째 세션에서 소진 방식 정정)**: 값이 새로 `:Set()` payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로
때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 `:Set()` 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만
순회하면서 `type(v) == "thread"``:Wait()`가 만든 대기자로 보고 순회하면서 `type(v) == "thread"``:Wait()`가 만든 대기자로 보고
**`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을
resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield
경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()` 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`
리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value` 리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value`
넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서 넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서
이번 세션에 정정) 후 **`[i] = None`**(**`nil`이 아님** — 아래 정정) 후 **`[i] = nil`**로 소진(아래 "왜 `None`이 아니라 `nil`인가"
"왜 `nil`이 아니라 `None`인가" 참고), 아니면 일반 콜백 함수로 보고 참고), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 여전히
그냥 `v(value)`(콜백은 여전히 원래 값을 직접 받음, 소진 안 함, 계속 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을
유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능
값 타입 하나로 분기 가능(`type(v) == "thread"` → 대기자, (`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백,
`type(v) == "function"` → 콜백, 그 외/`None` → 빈 슬롯이라 스킵). `nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`
새 콜백/대기자 등록은 `table.insert`로 끝. 아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는
- **왜 `nil`이 아니라 `None`인가(2026-08-07 열 번째 세션, 사용자가 실제 등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그
Luau REPL로 반례 제시 후 정정) — 이전 서술("성긴 배열이어도 일반화 자리를 되찾아 쓸 수 있음.
`for`가 계속 잘 순회하므로 압축 불필요")은 절반만 맞았음.** 대기자/콜백 - **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정)
자체는 순서가 안 중요해서(어느 게 먼저 fire되든 상관없이 전부 fire되기만 — 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안
하면 됨) "잘 순회함"까지는 맞았지만, 두 가지를 놓쳤음: (1) 키가 촘촘한 맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는
저범위 정수(1,2,3,...)에서 벗어나 듬성듬성해지면(`nil`로 지운 슬롯도 정수 키가 해시 파트로 튀어 순회 순서가 깨짐, `table.insert``#t`
포함) Luau/Lua 테이블이 그 키들을 해시 파트로 취급해 순회 순서가 해시 구멍 있는 테이블에서 미정의 동작)는 **순서가 실제로 중요한 배열**
버킷 순서가 되어버림(사용자가 `{[1]=1,[2222]=2222,[211]=211,...}` (`PreRef` pre-pass, Length/Offset의 `sourceList``1..N` 고정
REPL 실측으로 확인 — 대기자/콜백 리스트 자체는 이 순서 소실이 문제 범위로 도는 `for` 루프라 구멍이 있으면 안 됨)에는 맞는 처방이지만,
안 되지만, 순서가 실제로 중요한 다른 배열(`PreRef` pre-pass 등)엔 Ref의 콜백/대기자 배열은 애초에 **순서가 중요하지 않다**(어느 게
치명적). (2) `table.insert`가 내부적으로 쓰는 `#t`(length 연산자)는 먼저 fire되든 전부 fire되기만 하면 됨) — 일반화 `for i,v in tbl do`
Lua 명세상 구멍이 있는 테이블에서 **정의되지 않은 동작**이라, 다음 구멍이 있어도 순서가 뒤섞여도 **모든 엔트리를 빠짐없이 방문**하므로
콜백/대기자 등록이 엉뚱한 인덱스에 들어가 기존 항목을 덮어쓸 위험이 "순서 보장이 깨진다"는 문제 자체가 이 배열엔 없음. 오히려 `None`
있음 — 이건 대기자/콜백 리스트에도 실제로 해당하는 진짜 버그. 쓰면 소진된 슬롯이 영원히 non-nil로 채워진 채 남아 **매 `:Wait()`
`None``nil`이 아닌 **실재하는 값**이라 그 슬롯이 "차 있다"는 사실 호출마다 배열이 끝없이 길어지는** 새 문제가 생김(등록이 항상 끝에만
자체는 안 바뀌므로 두 문제 다 피함 — 소진된 슬롯도 여전히 non-nil 추가되고 예전 슬롯을 재사용 못 함) — `nil`로 지우면 다음 등록이 그
값을 갖고 있어 테이블이 "구멍 없는 시퀀스"라는 불변식이 깨지지 않음. 빈 슬롯을 재사용할 수 있어 배열 크기가 동시 대기자 수만큼만 유지됨.
`table.insert``#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈
슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던
이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이
필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열
(PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속
`None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할
이유가 없었음.
- **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를
`:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건
다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로
@ -894,11 +929,14 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
`Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번 `Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번
순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며
`isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i] `isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i]
= None`**으로 소진(`nil`이 아님 — 위 "왜 `nil`이 아니라 `None`인가" = None`**으로 소진(`nil`이 아님, 2026-08-07 열 번째 세션 정정: `nil`
절과 같은 이유, 2026-08-07 열 번째 세션 정정: `nil`로 지우면 그 지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회
순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는
보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 바로 그 보장이라 치명적. **[주의, 2026-08-09 열한 번째 세션] Ref
보장이라 치명적). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 자신의 콜백/대기자 배열은 이 이유가 적용되지 않아 `nil` 소진으로
되돌아갔음(위 "Ref 일반화" 절 참고) — 여기 PreRef pre-pass는 순서
보장이 실제로 필요한 별개 케이스라 `None` 소진이 계속 맞음, 두
사례를 혼동하지 말 것**). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가
**같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상 **같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상
`Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이 `Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이
직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용 직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용
@ -918,6 +956,17 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
`Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가 `Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가
엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은 엉뚱하게 매치되어 **정상적인 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 - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props
순회 순서" 절은 `{a=1, 2, b=3}`**구멍 없는** 테이블에서 배열 순회 순서" 절은 `{a=1, 2, b=3}`**구멍 없는** 테이블에서 배열
파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(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`를 리턴(대칭)** — - **`:Subscribe()`/`:Unsubscribe()` 둘 다 `self`를 리턴(대칭)** —
`local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에 `local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에
끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn) 끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn)
@ -1724,6 +1785,20 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사
구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})` 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`
같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 같은 스타일로 지원(`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]** 아래 두 문장은 이후 라운드에서 정정된 옛 서술 — **[정정, 2026-08-07]** 아래 두 문장은 이후 라운드에서 정정된 옛 서술 —
실제 메커니즘·mutate 취급은 `base/store-semantics.md` "Source가 State를 실제 메커니즘·mutate 취급은 `base/store-semantics.md` "Source가 State를
만족함" 절이 최종 소스: (a) "`__newindex`/`__index` 프록시로 감싸면 만족함" 절이 최종 소스: (a) "`__newindex`/`__index` 프록시로 감싸면
@ -1961,16 +2036,27 @@ Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교
**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — **`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** —
단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 단순 항등인 경우(`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) local function isSource(x)
return Brand.get(x) == SourceTag return Brand.get(x) == SourceTag
end 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`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을
@ -1985,20 +2071,25 @@ end
불필요" 서술도 같이 정정 대상. 불필요" 서술도 같이 정정 대상.
**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 **갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것
추가(2026-08-07 열 번째 세션).** 위 코드 예시가 원래 `RefTag`/ 추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가
`PreRefTag`/`ModifierTag`를 안 만들어뒀는데, 이 문서 곳곳(PreRef 절의 재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를
`isPreRef(v)`, `component-composition-plan.md``isModifier(v)` 등)이 `isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로
이미 이 predicate들이 존재한다고 전제하고 써왔음 — 실제로 만들어야 하는 취급(`isRef(preRefInstance)`가 `false`)했으나, 이건 `isState`/`isSource`
게 맞아서 태그 목록에 추가. **`isRef`/`isPreRef`는 `isObserver`와 같은 쌍과 비일관적이었음 — `Source`가 State를 구조적으로 만족하듯,
단순 항등**(`isRef(x) = Brand.get(x) == RefTag`, `isPreRef(x) = **`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함
Brand.get(x) == PreRefTag`) — `isState`처럼 집합 멤버십이 아님, 즉 방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘.
**`isRef(preRefInstance)`는 `false`.** 이게 중요한 이유: `PreRef`
"Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 해서 `isRef` - **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) ==
`PreRef`도 통과시키면, 일반 `(v=Ref)` 매치 핸들러가 `PreRef` 인스턴스도 PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로
집어삼켜버려 위 "PreRef" 절이 요구하는 "일반 Ref 경로를 절대 타면 안 얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)``true`.**
됨"이 깨짐 — `Ref`/`PreRef`는 State/Source 같은 상하위 관계가 아니라 - **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는
서로 배타적인 형제 브랜드. `isModifier`도 같은 단순 항등 이제 `isHandlable``isRef(v) and not isPreRef(v)`로 명시적으로
(`Brand.get(x) == ModifierTag`). 좁혀야 함** — 예전처럼 `isRef` 자체가 배타적이라 저절로 걸러지는 게
아니라, "Ref이긴 한데 그 중 PreRef는 아니다"를 호출부가 명시적으로
말해야 하는 모양으로 바뀜(PreRef pre-pass가 이미 소진시켜 정상 경로에선
거의 안 걸리지만, 위 "PreRef" 절의 동적 경로 가드 Handler와 이 조합이
같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장). `isModifier`도 같은
단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념 없음).
**같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — **같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** —
`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 `Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등

View file

@ -90,13 +90,29 @@ Svelte `Writable<T> extends Readable<T>`와 같은 모양), `store.key`는 Store
생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게
일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함"). 일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함").
### 5. 리프(Roblox 프로퍼티) 바인딩엔 원칙적으로 State만 ### 5. 리프(Roblox 프로퍼티) 바인딩 — Source 직접 바인딩도 정상 경로,
"좁은 예외"라는 표현이 오해를 유발해 정정함(2026-08-09 열한 번째 세션)
계산된 최종값만 실제로 인스턴스에 반영되어야 하므로, 리프 바인딩은 State가 **[정정] 이전 서술("Source는 리프 바인딩용 프리미티브가 아니라 좁은
일반 경로. Source는 리프 바인딩용 프리미티브가 아니라, 아주 단순한 구조에서 용도의 예외")은 부정확했음 — 사용자가 직접 반례를 제시:
콜백 보일러플레이트를 줄이기 위한 좁은 용도의 예외 — **사용자 확정** `local a = Source(true); Frame { Visible = a }; a:Set(false)`처럼
("리프 바인딩엔 state만 쓰이지 않을까... source는 그냥 아주 단순한 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 경계 통과 문제 관련) ## 프레임워크 사례 조사 (2026-08-04, modifier/Ref 경계 통과 문제 관련)
@ -206,8 +222,12 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴
버그 실측 후 확정).** caller가 `props.Modifier`/`props.Ref`를 안 넘기면 버그 실측 후 확정).** caller가 `props.Modifier`/`props.Ref`를 안 넘기면
`nil`인데, `{nil, props.Ref, child}`처럼 Lua 배열 리터럴에 `nil`이 그대로 `nil`인데, `{nil, props.Ref, child}`처럼 Lua 배열 리터럴에 `nil`이 그대로
들어가면 그 순간 테이블의 배열 파트 전체가 순회 순서 보장을 잃을 위험이 들어가면 그 순간 테이블의 배열 파트 전체가 순회 순서 보장을 잃을 위험이
있음(`base/bind-system-plan.md` "왜 `nil`이 아니라 `None`인가" 절 — Luau 있음(`base/bind-system-plan.md`의 PreRef pre-pass 절이 다루는 것과 같은
REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 테이블 전체에 영향). 부류의 문제 — Luau REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라
테이블 전체에 영향. **[주의]** 순서가 안 중요한 Ref 자신의 콜백/대기자
배열은 반대로 `nil` 소진이 맞다는 정정이 따로 있음 — 여기서 다루는 건
컴포넌트가 넘기는 **리터럴 children 배열**(순서가 중요한 배열)이라
그 정정과 무관하고 `None` 관용구가 계속 맞음).
그래서 **컴포넌트 저작자는 항상 `or None`으로 감싸서 넘겨야 함**: 그래서 **컴포넌트 저작자는 항상 `or None`으로 감싸서 넘겨야 함**:
```luau ```luau
return Frame { props.Modifier or None, props.Ref or None, child } return Frame { props.Modifier or None, props.Ref or None, child }
@ -254,7 +274,12 @@ Fusion/Vide(named prop 전달, `[Children]`류 예약 키)가 서로 다른 이
파라미터를 선언하지 않으면 그만 — 타입 시그니처 자체가 "나는 단일 대상에게 파라미터를 선언하지 않으면 그만 — 타입 시그니처 자체가 "나는 단일 대상에게
적용할 modifier/Ref가 없다"를 표현. 별도 조율 메커니즘 불필요 — **사용자 적용할 modifier/Ref가 없다"를 표현. 별도 조율 메커니즘 불필요 — **사용자
확정**("불가능하진 않고 기술적으로도 충분히 되는 일... 엄청 집중해야할 확정**("불가능하진 않고 기술적으로도 충분히 되는 일... 엄청 집중해야할
일은 아니지 않을까"). 일은 아니지 않을까"). **[재확인, 2026-08-09 열한 번째 세션]** 새 배선
없이 그대로 작동함을 재확인 — `Frame { Comp{} }`에서 `Comp``Slot`
반환하면, 그 반환값이 그냥 children 배열의 한 항목(값)이 되고
`Dispatch/Slot.luau`의 기존 Slot 매치 핸들러가 평소처럼 처리(값이
컴포넌트 호출로 왔든 리터럴로 직접 놓였든 디스패치 입장에선 구분이
없음) — 이 경로 전용 특수 취급이 전혀 필요 없다는 뜻.
이 정리로 원래의 "모호해지는 케이스"는 사라짐: 컴포넌트가 단일 root를 갖는 이 정리로 원래의 "모호해지는 케이스"는 사라짐: 컴포넌트가 단일 root를 갖는
한 named parameter로 명확히 전달되고, 단일 root가 없는 컴포넌트(Slot 반환)는 한 named parameter로 명확히 전달되고, 단일 root가 없는 컴포넌트(Slot 반환)는

View file

@ -58,6 +58,31 @@ leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백
leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 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 **Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React
`useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나, `useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나,
클로저 업밸류로 이미 충분해 채택 안 함. 이 기각은 위 Effect 설계와 클로저 업밸류로 이미 충분해 채택 안 함. 이 기각은 위 Effect 설계와
@ -85,6 +110,17 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님, 자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님,
기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속 기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속
살아있음(Observer와 동일 관용구). 살아있음(Observer와 동일 관용구).
- **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`
묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지
않는 게 정상 경로.** `:Subscribe()`를 쓰기로 했다면(top-level이든
의도적으로 다른 경우든) **반드시 `:Unsubscribe()`로 짝을 맞춰야
함** — 강참조 레지스트리는 quad 전역의 "정리는 기본적으로 GC에
위임" 원칙의 **의도적 예외**라, 로컬 변수 참조를 다 놓아도(스코프를
벗어나도) **GC되지 않고 계속 실행됨**. 이건 quad의 다른 프리미티브
대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자
문서에 명시적으로 경고할 것(`:Subscribe()`를 부르는 순간부터 그
핸들의 생애주기는 전적으로 수동 관리 대상이 됨).
- **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect - **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect
계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만
끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의

View file

@ -206,6 +206,17 @@ UB로 남겨둠")은 폐기. 재검토 근거(사용자): Modifier는 애초에
- **State/Source는 여전히 허용** — 이 체크는 핸들러 계층 값만 잡음, - **State/Source는 여전히 허용** — 이 체크는 핸들러 계층 값만 잡음,
4-1번 절의 "필드가 State일 수도 있음"과 안 부딪힘(`isState`가 참인 4-1번 절의 "필드가 State일 수도 있음"과 안 부딪힘(`isState`가 참인
값은 이 체크를 그냥 통과함). 값은 이 체크를 그냥 통과함).
- **한계, 명시적 UB로 남김(2026-08-09 열한 번째 세션) — `State<Ref>`
"State/Source가 담고 있는 값"이 핸들러 계층 값인 경우는 이 체크로
못 잡음.** `isRef(v)` 등은 setter가 확정하는 바로 그 값(State 자체
또는 plain 값)만 보므로, 값이 State/Source면 그 껍데기가 `isState`
통과해 검사를 그냥 지나가고, 그 State가 나중에 `:Get()`됐을 때 실제로
내놓는 내용물(예: 그 State가 Ref/PreRef/Observer/Effect/Slot을 값으로
들고 있는 경우)까지는 검사하지 않음 — 검사 시점엔 아직 실체화 안 된
값이라 정적으로 알 수 없고, 값이 바뀔 때마다 매번 `:Get()`해서
검사하는 건 관측 시점을 앞당기는 부작용까지 생기는 오버엔지니어링.
**이 안쪽 케이스는 방어 로직 없는 순수 UB로 문서화만 하고 넘어감**
의도치 않게 자주 발생할 이유가 없는 조합이라 실사용 위험은 낮음.
- **7번 절(`State<Modifier>` UB)과의 비대칭이 이걸로 줄어듦** - **7번 절(`State<Modifier>` UB)과의 비대칭이 이걸로 줄어듦**
`pre-implementation-audit.md`가 지적했던 "같은 문서 안에서 한쪽은 `pre-implementation-audit.md`가 지적했던 "같은 문서 안에서 한쪽은
방어(타입 차단 시도), 한쪽은 무방비 UB"라는 비일관성이, 이제 둘 다 방어(타입 차단 시도), 한쪽은 무방비 UB"라는 비일관성이, 이제 둘 다

View file

@ -205,53 +205,73 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데
## CRUD API 확정 (2026-08-09 세 번째 세션, 1-7 해소) ## 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` 생략 시 끝에 추가 | | `Add` | `Slot:Add(element, index?)` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 |
| `Remove` | `Slot:Remove(element)` | O(n) | 제거 **+ 파괴**(retract/Destroy) | | `Remove` | `Slot:Remove(index)` | O(n) | 제거 **+ 파괴**(retract/Destroy) — `Extract(index):Destroy()`와 동치, 흔한 경로라 별도 이름으로 유지 |
| `Extract` | `Slot:Extract(element)` | O(n) | 제거, **파괴 안 함** — 호출부가 소유권 회수, 임의의 다른 Slot에 재삽입 가능 | | `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 | | `Clear` | `Slot:Clear()` | O(n) | 전체 `Remove`(전부 파괴) — 빈 Slot에 호출해도 no-op |
| `Move` | `Slot:Move(element, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** | | `Move` | `Slot:Move(oldIndex, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** |
| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 **인덱스**의 요소를 맞교환, 나머지 안 건드림, **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`/ - **`Extract(index, newElement?)`가 존재하는 이유** — 인덱스 기준 모델에서
`Move`) — element는 컴포넌트 호출/Instance 생성이 만들어낸 구체 값이라 "요소 하나를 다른 걸로 교체"하려면 `Extract(index)`(O(n) 시프트) 후
항상 아이덴티티로 구분 가능. 인덱스 기준으로 하면 add/remove가 반복될 `Add(newElement, index)`(O(n) 시프트 재발생)를 따로 불러야 해서 이중으로
때 호출부가 들고 있던 인덱스가 곧바로 stale해짐. 무거움. `newElement`를 같이 넘기면 그 자리 값을 시프트 없이 바로
- **`Swap`만 예외 — 인덱스 기준(`indexA`/`indexB`), element 레퍼런스 갈아끼우기만 하면 되므로 훨씬 쌈 — 별도 `Set`이라는 이름 대신 `Extract`
아님(2026-08-09 세 번째 세션 정정).** element 레퍼런스로 받으면 Slot이 확장으로 둔 이유는 반환값이 "이전 element"라는 의미가 `Extract`
element→index 역방향 맵을 안 갖고 있는 이상 두 element 각각의 현재 정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일
위치를 찾는 데 O(n)씩(총 2n) 들어서 **`Swap`이 약속하는 O(1)이 깨짐** 뿐). `newElement`에도 `Add`와 같은 검증(이미 마운트/타입 제약)이
`Move`는 어차피 시프트 자체가 O(n)이라 element 조회 비용이 묻히지만, 똑같이 적용됨.
`Swap`은 조회 비용이 곧 전체 비용이라 이 차이가 그대로 드러남. 호출부는 - **`Get`/`IndexOf` 신설, 원래 "YAGNI"로 뺐던 것을 재추가.** 처음엔
보통 "지금 몇 번째 항목과 몇 번째 항목을 바꿀지"를 이미 알고 있는 "`:List`가 자기 key→element 맵을 따로 들고 있어 Slot 내부 상태 조회가
상황(예: 드래그 리오더 UI)이라 인덱스로 받는 게 자연스럽기도 함. 불필요"하다고 판단해 드롭했으나, 위 인덱스 기준 전환과 맞물려 다시
- **열거(iteration)/`Get` API는 지금 안 만듦**`Slot:List(...)`(아래)는 필요해짐 — element 레퍼런스만 갖고 있는 호출부가 인덱스 기반 CRUD를
자기 자신의 key→element 맵을 따로 들고 있어 Slot의 내부 상태를 조회할 쓰려면 `IndexOf`가 유일한 다리. `Get`은 대칭성/일반적인 컬렉션 API
필요가 없음. 다른 실사용이 나오면 그때 추가(YAGNI). 완결성을 위해 같이 열어둠(필수까진 아니지만 비용이 거의 없어 열어둠).
- **`raw*` 내부 호출 규약은 공개 API와 다를 수 있음(구현 세부, M6에서
확정)** — `:List`의 reconcile은 이미 자기 `key→element` 맵을 들고
있어서 `rawRemove`/`rawMove` 등을 element 기준으로 계속 부를 수도
있음. 공개 CRUD가 인덱스를 받아 내부적으로 element를 찾아 `raw*`
넘기는 얇은 변환 계층이 될지, `raw*` 자체를 인덱스 기준으로 통일할지는
base 설계가 못박을 필요 없는 구현 디테일.
- **에러 조건 — 전부 즉시 `error()`, no-op 없음**(기존 "재마운트 시 throw"와 - **에러 조건 — 전부 즉시 `error()`, no-op 없음**(기존 "재마운트 시 throw"와
같은 fail-fast 톤): 같은 fail-fast 톤):
- `Add`: element가 이미 어딘가(같은 Slot이든 다른 Slot이든) 마운트돼 - `Add`: element가 이미 어딘가(같은 Slot이든 다른 Slot이든) 마운트돼
있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을 있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을
CRUD 경로에도 동일 적용. `element``nil`/`None`이거나 핸들러 계층 CRUD 경로에도 동일 적용. `element``nil`/`None`이거나 핸들러 계층
값(Ref/PreRef/Observer/Effect/Modifier)이면 에러 — 위 "요소 타입 제약" 절. 값(Ref/PreRef/Observer/Effect/Modifier)이면 에러 — 위 "요소 타입 제약" 절.
- `Remove`/`Extract`: 그 element가 지금 이 Slot의 멤버가 아니면 에러. - `Remove`/`Extract`/`Move`: `index`(들)가 범위 밖(1..현재 개수)이면
- `Move`: `element`가 이 Slot의 멤버가 아니면 에러. 에러.
- `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖(1..현재 개수)이면 에러 — - `Extract(index, newElement)`: `newElement``Add`와 동일한 검증
`Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op. (이미 마운트/타입 제약) 적용.
- `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖이면 에러 — 단
`Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op.
- **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십 - **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십
weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/ weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/
`Remove`/`Extract`보다 저렴함. `Remove`/`Extract`보다 저렴함.
- **모든 공개 CRUD는 "가드 확인 + `raw*` 위임"의 얇은 wrapper**`Add`/ - **공개 CRUD 중 실제로 mutate하는 것(`Add`/`Remove`/`Extract`/
`Remove`/`Extract`/`Clear`/`Move`/`Swap` 전부 `self._listed`(`:List`가 `ExtractAll`/`Clear`/`Move`/`Swap`)은 "가드 확인 + `raw*` 위임"의 얇은
설치돼 있으면 수동 CRUD 금지)만 확인하고 실제 로직은 `rawAdd`/ wrapper** — `self._listed`(`:List`가 설치돼 있으면 수동 CRUD 금지)만
`rawRemove`/`rawExtract`/`rawClear`/`rawMove`/`rawSwap`에 있음 — 이 확인하고 실제 로직은 `rawAdd`/`rawRemove`/`rawExtract`/`rawClear`/
`raw*` 함수들이 `:List`의 reconcile이 가드 없이 직접 호출하는 바로 `rawMove`/`rawSwap`에 있음 — 이 `raw*` 함수들이 `:List`의 reconcile이
그 함수(아래 "`Slot:List`" 절의 "구현" 참고). 공개 메소드에 로직이 가드 없이 직접 호출하는 바로 그 함수(아래 "`Slot:List`" 절의 "구현"
따로 있는 게 아니라 전부 이 한 세트를 공유. 참고). 공개 메소드에 로직이 따로 있는 게 아니라 전부 이 한 세트를
공유. **`Get`/`IndexOf`는 순수 읽기라 이 가드 대상 아님** — `:List`
설치돼 있어도 자유롭게 호출 가능.
- **재진입성**(Observer/store-bind 재실행 콜백 안에서 `Add`/`Clear`를 - **재진입성**(Observer/store-bind 재실행 콜백 안에서 `Add`/`Clear`를
다시 호출) — 별도 가드 불필요. CRUD는 평범한 동기 테이블 뮤테이션 + 다시 호출) — 별도 가드 불필요. CRUD는 평범한 동기 테이블 뮤테이션 +
Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로 Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로

View file

@ -71,6 +71,7 @@ function TagHandler.process(inst, k, v)
end end
function TagHandler.retract(inst, k, v) function TagHandler.retract(inst, k, v)
assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")
local old = relate:GetStrong(inst, k) local old = relate:GetStrong(inst, k)
if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end
relate:SetStrong(inst, k, nil) relate:SetStrong(inst, k, nil)
@ -84,11 +85,16 @@ end
전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시 전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시
이전 값과 diff. 이전 값과 diff.
- **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract` - **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`
불림 — **`v`를 굳이 안 봐도 됨**: retract는 구조상 "더 이상 Tag가 불림. **[명시화, 2026-08-09 열한 번째 세션] 전체 삭제는 정확히
아니게 됐을 때만" 불리므로, 뭐가 새로 들어왔든 전체 삭제가 항상 맞는 `v == nil`일 때만 맞는 동작 — "v를 안 봐도 된다"가 아니라 "v가 항상
동작. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를 받는 건 계약 nil로 들어온다는 걸 알고 있으니 별도 분기가 필요 없다"가 정확한
일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가 그걸 필수로 표현.** Tag 값을 담는 키에서 TagHandler가 더 이상 매치 안 되는 유일한
요구해서가 아님. 경로가 값이 `nil`이 되는 것(`None → nil` 재디스패치 포함)이라 이
전제가 깨지지 않는 한 위 구현처럼 `v`를 실제로 분기 안 해도 항상
옳음 — 위 pseudocode에 `assert(v == nil, ...)`을 추가해 이 전제를
코드에도 드러냄. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를
받는 건 계약 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가
그걸 필수로 요구해서가 아님.
- **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** — - **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** —
`Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는 `Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는
자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은 자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은

View file

@ -63,6 +63,17 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/
자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍 자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍
(`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용). (`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 감사에서 지적된 항목, 여기서 같이 **기존 자식과의 매칭 기준(2026-08-06 감사에서 지적된 항목, 여기서 같이
확정)**: 재사용 대상은 quad가 이전에 만든 고정 이름(`_quad_corner`류) 확정)**: 재사용 대상은 quad가 이전에 만든 고정 이름(`_quad_corner`류)
자식으로 한정 — 타입만 보고(`UICorner`이기만 하면 아무거나) 재사용하지 자식으로 한정 — 타입만 보고(`UICorner`이기만 하면 아무거나) 재사용하지

View file

@ -216,12 +216,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
- **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 - **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는
패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로
올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요.
- **Attribute 특수 키 타입 파라미터화**`base/bind-system-plan.md` - **[해소됨, 2026-08-09 열한 번째 세션]** Attribute 특수 키 타입
"Attribute 특수 키" 절(2026-08-06 신규). `[Attribute<<boolean>> "name"]` 파라미터화 — `[Attribute<<boolean>> "name"]` 제네릭 스타일과
제네릭 스타일 vs `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 **둘 다 채택으로
중 뭘로 갈지 — 소견은 DI 인스턴스 생성 패턴처럼 "제네릭 하나 + 자주 확정**(내부 구현 동일, 호출부 표기만 다름). `base/attribute-plan.md`
쓰는 타입만 정적 지름길" 절충이지만 확정 아님, M10(Handlers/Attribute) 참고 — 제네릭 파라미터가 `=` 뒤 값 타입까지 좁혀주는지는 M0/M10에서
착수 전 아무 때나 확인해도 됨. 실측 필요(안 돼도 런타임엔 영향 없음).
- **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`** - **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`**
`research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴). `research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴).
방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy 방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy

View file

@ -0,0 +1,135 @@
# charm(littensy/charm) 비교 — quad-v2 설계 근거
**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. quad에 관한 결정
자체가 아니라 charm 리서치 스냅샷(2026-08-09, `.claude/initreq/charm`
새로 클론)이라 항상 읽어야 하는 base 컨텍스트는 아님 — Fusion/Vide 비교와
같은 성격, `quadnomicon` 소재 후보이기도 함. quad-v2의 Blocker/Effect/
Slot:List/(미래) 네트워크 동기화 설계에 근거로 인용될 때만 열어볼 것,
실제 확정 사항은 인용하는 쪽 `base/` 문서가 소스.
**charm이 뭔지**: Roblox용 Zustand류 상태관리 라이브러리 —
`atom`/`computed`/`subscribe`/`effect`/`batch` 핵심(`packages/charm/src/
init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) +
`react-charm`/`vide-charm`(얇은 어댑터). 코어는 실제로 절반쯤이 alien-signals
포크(`system.luau`, dirty/pending 비트플래그 전파 엔진, 237줄 — 가장 큰
테스트 파일이 이걸 검증하는 `topology.test.luau` 484줄)라 순수 서핏보다
알고리즘 실체가 있지만, quad는 노드/의존성 재사용 모델 자체를 안 쓰기로
이미 갈라섰으므로 이 부분은 이식 대상이 아님.
## 반면교사 — quad가 이미 기각/확정한 것과 충돌하는 부분
- **`batch(fn, ...)`가 quad가 이미 기각한 `Batch` 렉시컬 블록과 구조적으로
동일.** `init.luau:768-778``startBatch`/`endBatch`(`init.luau:285-296`)로
콜백을 감싸 effect flush를 지연시키는 모듈 전역 `batchDepth` 카운터
방식(`init.luau:66`) — `archive/batch-rejected.md`가 "코루틴 yield에
안전하지 않다"는 이유로 기각한 것과 정확히 같은 모양. **charm 자신도 이
위험을 인정하는 증거를 갖고 있음**: `wrapUserSpace()`(`init.luau:100-129`)가
signal/effect/batch 콜백을 `coroutine.create`/`resume`으로 감싸서 콜백 도중
yield를 시도하면 에러내는 가드(`flags.strict`, Studio 기본 on,
`init.luau:71-81`)를 따로 둠 — 위험을 런타임 가드로 땜질한 것이지 없앤 게
아님. quad는 원시 자체를 제거하는 쪽을 택했으니(`Blocker`가 그 자리를
대신함, `base/blocker-plan.md:25-44`) 이 모양을 참고할 이유 없음.
- **`atom()`의 getter/setter 겸용 콜러블이 quad가 `Store`에서 이미 기각한
대입 문법과 같은 트레이드오프.** `atom<T>(initialValue, equals?)`
(`init.luau:519-527`)가 인자 개수로 read/write를 분기하는 방식 —
`store.key = value`를 버리고 `store.key:Set(value)`로 간 이유
(`base/store-semantics.md:208-233`, 읽기/쓰기 타입 비대칭)와 같은 문제.
charm 스스로도 README(185-196행)에서 `atom()``signal()`(진짜 get/set
쌍) 위에 얹은 편의 sugar로 취급 — charm 안에서도 "진짜 1급 형태는 아니다"로
다뤄지는 걸 참고.
- **Effect가 전혀 GC-native가 아님 — 전부 수동 dispose 필요.** `effect`/
`effectScope`/`listen`/`subscribe` 전부 호출자가 직접 불러야 하는
`Cleanup` 함수를 반환(`init.luau:607-641`, `652-676`, `800-835`) — Roblox
Instance 라이프타임에 자동으로 묶이는 경로가 코어에 아예 없음. `base/
lifecycle-pattern.md`의 GC-native 원칙과 정반대 축. 오히려 `gc.test.
luau:19-33`의 코멘트가 "스코프 밖에서 `computed()`를 그냥 부르면 의존성에
대한 영구 강참조가 생겨서 `effectScope`로 감싸 명시적으로 풀어줘야
한다"는 걸 테스트 자체가 우회 헬퍼(`unlink()`, 29-33행)로 증명함 —
이건 quad의 GC-native 가정을 **뒷받침하는** 증거가 아니라, "레퍼런스/
클로저 기반 반응 그래프가 자동으로 안 치워질 수 있다"는 **반례**로
인용할 것(rbvm이 "실물 검증된 근거"로 인용되는 것과 반대 방향 — 나중에
quad의 GC-native 가정을 스트레스테스트할 때 이 케이스를 참고).
- **`computed()`의 값-동등성 억제가 기본값이자 암묵적, opt-in이 아님.**
`updateComputed``oldValue ~= newValue`(`init.luau:302-321`, 특히
317행)를 리턴하고 signal setter도 `equals`가 없으면 `node.pendingValue ~=
value`로 기본 비교(`init.luau:489`) — charm의 모든 atom/computed가 기본으로
값 비교 억제를 함. quad가 나중에 Blocker에 인접한 "값 안 바뀌면 자동
스킵" 기본값을 도입하고 싶어질 때, charm처럼 **모든 노드에 암묵적으로**
거는 방식은 `Blocker`가 이미 명시한 "특정 게이트 지점에서만 opt-in"
원칙(`base/blocker-plan.md:65-68`)과 "Source는 스스로를 자동 변형하지
않는다"는 `store-semantics.md` 기조에 둘 다 어긋남 — 반면교사로 남겨둘 것.
## 참고할만한 부분
- **charm의 `None` 센티널이 quad 자신의 것을 독립적으로 재확인해줌.**
`patch.luau:10,19-30`이 diff 페이로드에서 "안 바뀜"과 "명시적으로
지움"을 `nil`로는 구분 못 해서 `None = {__none="__none"}`을 따로
둔 이유 — quad의 배열/해시 파트 `None` 센티널 정당화(`base/
bind-system-plan.md:180-266`)와 동기 없이 같은 결론에 수렴한 사례.
새 아이디어는 아니고 인용 근거로만 가치 있음.
- **quad가 미결로 남긴 "previous 값 비교" 문제에 대한 두 가지 답.**
(1) `signal(initialValue, equals?)`(`init.luau:432`, `Equals<T>` 타입은
23행)는 생성 시점에 `initialValue`를 항상 요구해서 "비교할 이전 값이
아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰 —
`research/additional-primitives-plan.md`가 남겨둔 "비교할 이전 값이
확정 안 된 문제"에 대한 한 가지 해법 형태. (2) `computed(getter)`
getter에 **이전 계산 결과**를 인자로 넘겨줌(`init.luau:538`,
`(previousValue: T?) -> T`, README 276-287행, `computed.test.
luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad의
`store-semantics.md:280-284`가 이미 띄워둔 "`:Compute(fn)`에 선택적
두 번째 `previous` 인자" 안과 거의 동일한 모양. 새로 수입할 아이디어가
아니라 **이미 검토 중인 안이 실제로 동작한다는 정황 증거**로 인용
가치 있음.
- **charm-sync의 diff/patch 메커니즘 — quad가 아직 전혀 안 다뤄본 영역이라
가장 새로운 참고자료.** `patch.luau:59-89`(`diff`)가 재귀적 구조적
diff로 중첩 patch 테이블을 만들고, `apply`/`applyMutable`
(`patch.luau:91-131`)이 immutable 재구축(레벨마다 `table.clone`, 순수
signal용)과 in-place mutate+`:Emit()`류 변형(반응형 프록시용) 둘 다
제공 — quad가 이미 다른 이유로 갖고 있는 clone-vs-mutate+`Emit` 분리
(`base/store-semantics.md:240-284`)와 우연히 같은 모양. `patch.
luau:32-57`(`stringifySparseArray`)는 실전에서 놓치기 쉬운 페이로드
함정을 문서화함 — RemoteEvent/JSON 직렬화가 성긴 배열의 trailing hole을
조용히 드롭해서, 보낼 땐 문자열 키로 재인코딩하고 받을 땐 숫자 키로
복원해야 함(`patch.luau:101-107`). `server.luau`는 클라이언트별 관심사
필터링을 하나의 전역 diff 위에 구현(`clients` 테이블의
`PENDING_INITIAL_STATE`/`LISTENING_FOR_CHANGES` 상태, 27-32행,
`selectFromGlobalPatch` 209-250행) + 모든 중간 변경을 보존하는 opt-in
모드(`config.preserveHistory`, `diffGlobalUpdateBuffer`, 124-133행) vs
기본값인 flush당 diff 하나로 합치는 모드(`diffGlobalState`,
192-207행) — `Blocker`가 일반화하는 coalescing 트레이드오프의 손으로 짠
sync 전용 구현체. 지금 스코프 밖이지만 나중에 quad가 네트워크 복제
설계를 시작하면 첫 참고 지점으로 쓸 것.
- **`observe()`의 엣지케이스 테스트 스위트가 `Slot:List` 테스트 체크리스트로
재사용할 만함.** `observe.test.luau`가 마운트 콜백 도중의 재귀적
add/remove(92-113행), 자기 마운트 도중 자기 자신 제거(115-132행), add/remove
도중 dispose(134-168행), 재귀적 업데이트 중 에러가 reconciler를 안 멈추게
하는지(170-196행)를 검증 — `observe()` 자신의 메커니즘(키별
`effectScope`, `init.luau:851-898`)은 quad가 채택한 방식이 아니지만,
테스트 항목 목록 자체는 `base/slot-plan.md`의 키 기반 재조정을 실제
구현할 때 대조 체크리스트로 쓸 가치가 있음.
## 종합
코어(atom/computed/effect/subscribe/batch, `init.luau`의 절반쯤)는 평범한
시그널 라이브러리라 quad가 이미 확정한 것을 대체로 재진술할 뿐이고, 세
군데(`batch()`, `atom()`, 수동 dispose Effect)는 오히려 quad가 이미 능동
기각한 패턴을 그대로 구현하고 있음 — 사용자가 애초에 예상한 "짧은
라이브러리라 새로운 게 없을 것"이 이 레이어에는 대체로 맞음. 진짜 참고
가치는 코어 밖에 있음: charm-sync의 diff/patch(현재 quad 스코프 밖이지만
새 영역), 그리고 quad가 미결로 열어둔 Blocker의 "previous 값 비교" 문제에
대한 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`
previous-in-getter). 지금 당장 base 문서를 고칠 만한 발견은 없음 — 순수
참고자료로 등록.
**인용 위치**: `packages/charm/src/init.luau:66,71-93,100-129,285-296,
302-321,432,489,519-527,538,607-641,652-676,768-778,800-835,851-898` ·
`packages/charm/src/system.luau`(전체, alien-signals 포크) ·
`packages/charm/test/gc.test.luau:9-33` · `packages/charm/test/
computed.test.luau:84-104` · `packages/charm/test/observe.test.luau:92-196` ·
`packages/charm-sync/src/patch.luau:10,19-30,32-57,59-89,91-131` ·
`packages/charm-sync/src/server.luau:27-32,124-133,192-207,209-250` ·
`README.md:185-196,262-287` · `base/store-semantics.md:208-233,240-284` ·
`base/blocker-plan.md:25-44,65-68` · `base/lifecycle-pattern.md`(GC-native
원칙) · `archive/batch-rejected.md` · `base/bind-system-plan.md:180-266`
(None 센티널) · `research/additional-primitives-plan.md`(Blocker/키 기반
컬렉션 미결 상태).

View file

@ -73,7 +73,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부
- skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약 - skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약
### component-composition-plan.md / module-lifecycle-plan.md ### 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 생성 시그니처 / 모듈 싱글톤 스코프 - 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 체이닝 연산 폐기 - 심화: 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 네이밍 미정 등 열린 질문 메모 - skip: Compose/Fusion/Vide/v1 프레임워크 비교 원자료 / provider/processor 네이밍 미정 등 열린 질문 메모
@ -220,14 +220,26 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**:
## 5. 문서화 아직 보류(미확정 설계라 쓰면 안 됨) ## 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 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름, 트윈 옵션 값 - Tween 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름, 트윈 옵션 값
모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) 모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) — 아직 열림.
- `Attribute<T>` 제네릭 vs 타입별 정적 생성자 (`bind-system-plan.md`) - **[해소됨]** `Attribute<T>` 제네릭 vs 타입별 정적 생성자 — 2026-08-09
- provider/processor 네이밍 (`module-lifecycle-plan.md`) 열한 번째 세션에 "둘 다 채택"으로 확정, `base/attribute-plan.md` 참고.
- 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 - provider/processor 네이밍 — **[해소됨]** `Handler`로 이미 오래전 확정
후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ (`base/module-lifecycle-plan.md`), 이 줄이 그 갱신을 놓치고 있었음.
additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) - **[해소됨]** 키 기반 동적 컬렉션 재조정 최종 이름/시그니처, `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, - **"hook"/"pre-hook" 용어 채택 여부 + `PreRef`의 취소 가능성** (2026-08-07,
위 심화 후보 6번 참고) — `bind-system-plan.md``PreRef`가 위치 무관 위 심화 후보 6번 참고) — `bind-system-plan.md``PreRef`가 위치 무관
호이스팅이라는 것과 일반 `Ref`가 우선순위 스캔에 참여한다는 것까지는 호이스팅이라는 것과 일반 `Ref`가 우선순위 스캔에 참여한다는 것까지는

120
CLAUDE.md
View file

@ -2306,3 +2306,123 @@ lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed`
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수
설계 확정이라 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<Ref>`
"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<<T>>` 제네릭 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단계)까지 끝났는지, 사용자가 이어서 더 볼 부분이 있는지는
다음 세션 시작 시 확인.

View file

@ -82,8 +82,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`Brand.get(x)``isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ `Brand.get(x)``isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
`isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/ `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/
`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 `isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래
태그 목록에서 빠져있었음, `isRef`/`isPreRef`는 단순 항등이지 태그 목록에서 빠져있었음. **[정정, 2026-08-09 열한 번째 세션]**
`isState`처럼 집합 멤버십 아님) 전부의 기반. `isNone`만 예외로 `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` 레지스트리 없이 `x == None` 항등 비교 — `bind-system-plan.md`
`Brand` 절, 2026-08-07 여덟 번째 세션 신설) `Brand` 절, 2026-08-07 여덟 번째 세션 신설)
- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — - [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
@ -195,21 +200,31 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State<number>` bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
- [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론 - [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지 `Get`/`IndexOf` CRUD 의미론 확정** (2026-08-09 세 번째 세션, 2026-08-09
전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 열한 번째 세션에 식별 기준 재정정) — 에러 조건까지 전부 확정
throw"도 `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 (`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도
기준이 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절). `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이
`Move(element, newIndex)`(O(n))/`Swap(indexA, indexB)`(O(1), element 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
아닌 인덱스 — element면 위치 조회에 2n 들어 O(1) 약속이 깨짐)은 **[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서
리오더 전용, Parent를 안 건드림. 공개 6개 메소드 전부 "가드 확인 + 인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)`
`raw*` 위임" 얇은 wrapper. base/roblox 경계에 mount/unmount 외 (O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA,
reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소 타입 제약 확정** indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접
`nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을
`T`만), 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier)은 안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안
self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(`Modifier` 맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를
필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = Slot<<Instance>>` 구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?`
quad-roblox의 사실상 유일한 Slot 타입. 신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)`
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
`IndexOf`는 순수 읽기라 가드 대상 아님). base/roblox 경계에
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
Slot<<Instance>>`가 quad-roblox의 사실상 유일한 Slot 타입.
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, - [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
`keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity `keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity
보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프). 보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프).
@ -294,8 +309,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을
우회한 버그라는 뜻 — 같은 절 참고 우회한 버그라는 뜻 — 같은 절 참고
- [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 - [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면
`coroutine.resume(v, self)`+`None`으로 소진(`nil` 아님 — `coroutine.resume(v, self)`+`nil`로 소진(2026-08-09 열한 번째
2026-08-07 열 번째 세션 정정, `#t`/`table.insert` 안전성), 함수면 세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 `None`이 아닌
`nil`, `table.insert` 대신 빈 슬롯 선형 탐색 등록), 함수면
`v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)` `v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`
`thread``nil`이면 `thread``nil`이면
`coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self`