From 6b73cb420863cef2255d5f1e2aa667c383202bb8 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 13 Aug 2026 18:06:24 +0900 Subject: [PATCH] =?UTF-8?q?refactor(base):=20bind-system-plan.md=201?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EB=B6=84=ED=95=A0=20=E2=80=94=20Ref/PreRe?= =?UTF-8?q?f=C2=B7=EC=9D=B4=EB=B2=A4=ED=8A=B8=C2=B7Brand=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20=EB=AC=B8=EC=84=9C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적: 2989줄이라 검토가 불가능하고 한 곳의 실수가 미치는 범위가 너무 큼. 특히 "이벤트 연결은 다른 base 문서가 되어야 할 듯"이라고 직접 지목. **1단계 (완료, 2989 → 2263줄)** — 인바운드 참조가 적고 자기 완결적인 것부터: - base/ref-plan.md (509줄) — Ref/PreRef 전체 - base/event-plan.md (103줄) — 이벤트 self 미전달, false로 disconnect - base/brand-plan.md (137줄) — Brand 런타임 nominal 판별 내용은 **순수 이동, 결정은 하나도 안 바뀜**. 원위치엔 포인터 스텁을 남기고, 이 절들을 지목하던 인바운드 참조 20곳을 새 파일로 갱신(잔존 0 확인). README 색인에 3개 행 추가. **2단계는 의도적으로 미룸** — 남은 디스패치 코어(~1000줄)/반응형 코어 (~950줄)는 0-Z 확정 시 어차피 전면 재작성 대상이라, 재작성하는 그 패스에서 파일을 가르는 게 총 변경량과 실수 위험이 모두 작음(인바운드 ~37곳을 한 번만 고침). dispatch-redispatch-diff-plan.md 6절에 그 지시를 명시했고, bind-system-plan.md 헤더에도 분할 현황표를 남김. 부수: 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 쪼개면 양쪽이 반토막 나므로 원위치 유지(event-plan.md 헤더에 명시). luau-test/README의 "에이전트가 직접 실행 못 함" stale 서술도 정정. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y6hzeUi5QdLPEk69B6cXFa --- .claude/README.md | 3 + .claude/archive/agent-mistake.md | 2 +- .claude/archive/ref-phase-option-reversed.md | 2 +- .../archive/retract-always-fires-reversed.md | 2 +- .claude/base/architecture.md | 2 +- .claude/base/attribute-plan.md | 2 +- .claude/base/bind-system-plan.md | 778 +----------------- .claude/base/brand-plan.md | 146 ++++ .claude/base/component-composition-plan.md | 2 +- .claude/base/effect-plan.md | 2 +- .claude/base/event-plan.md | 118 +++ .claude/base/modifier-plan.md | 6 +- .claude/base/ref-plan.md | 519 ++++++++++++ .claude/luau-test/README.md | 11 +- .claude/question.md | 4 +- .claude/research/debug-tooling-plan.md | 2 +- .../research/dispatch-redispatch-diff-plan.md | 7 + .claude/research/documentation-content-map.md | 2 +- .claude/research/documentation-plan.md | 4 +- ROADMAP.md | 8 +- 20 files changed, 855 insertions(+), 767 deletions(-) create mode 100644 .claude/base/brand-plan.md create mode 100644 .claude/base/event-plan.md create mode 100644 .claude/base/ref-plan.md diff --git a/.claude/README.md b/.claude/README.md index 5a89269..897a75a 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -44,6 +44,9 @@ | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | +| `ref-plan.md` | **[2026-08-13 여덟 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** | +| `event-plan.md` | **[2026-08-13 여덟 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 `false`를 넣으면 disconnect. 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | +| `brand-plan.md` | **[2026-08-13 여덟 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`), `isState`를 10종 branded 타입 전부로 일반화. 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **분리는 순수 이동** | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) diff --git a/.claude/archive/agent-mistake.md b/.claude/archive/agent-mistake.md index 67c39ef..5d1977a 100644 --- a/.claude/archive/agent-mistake.md +++ b/.claude/archive/agent-mistake.md @@ -34,6 +34,6 @@ predicate(핸들러가 이 키/값을 담당하는지 판단, 핸들러 계약 4 `isState`만으론 부족함 — `isSource`를 별도로 제공해야 함. `isState`는 여전히 `{State, Source}` 둘 다 통과시킴(상위집합 판별 유지). -**현재 유효한 설계**: `base/bind-system-plan.md`의 `Brand` 절 +**현재 유효한 설계**: `base/brand-plan.md`의 `Brand` 절 (`isState`/`isSource`가 둘 다 존재, 전자는 집합 멤버십, 후자는 단순 항등)이 최종 소스. diff --git a/.claude/archive/ref-phase-option-reversed.md b/.claude/archive/ref-phase-option-reversed.md index 69403fa..960228a 100644 --- a/.claude/archive/ref-phase-option-reversed.md +++ b/.claude/archive/ref-phase-option-reversed.md @@ -12,7 +12,7 @@ ## 역전된 사례 — 원래 무엇을 확정했었나 **1. Ref는 dispatch 레지스트리의 "평범한 참가자"였음** (2026-08-04 -원문, `bind-system-plan.md` "Ref — 도입 확정" 절): +원문, `ref-plan.md` "Ref — 도입 확정" 절): > **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯 > `CreatedRef` 같은 값을 숫자 키 슬롯에 넣는 방식 — child와 동일한 diff --git a/.claude/archive/retract-always-fires-reversed.md b/.claude/archive/retract-always-fires-reversed.md index ccf1c8d..16802a8 100644 --- a/.claude/archive/retract-always-fires-reversed.md +++ b/.claude/archive/retract-always-fires-reversed.md @@ -5,7 +5,7 @@ (Tag의 `assert(v==nil)` 명시화). **현재 유효한 설계**: `base/bind-system-plan.md`의 일반 retract 계약 절(`retract(inst,k,v)` 항목), `base/tag-plan.md`/`base/attribute-plan.md` -"이름 소유권"/"메커니즘" 절, `base/bind-system-plan.md`의 "`Ref`의 retract" +"이름 소유권"/"메커니즘" 절, `base/ref-plan.md`의 "`Ref`의 retract" 절, `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절이 최종 소스. ## 역전된 사례 — 원래 무엇을 확정했었나 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 6d3bbbb..ec5b583 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -161,7 +161,7 @@ quad/ │ ├── 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 여섯 번째 세션에서 분리) +│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ └── init.luau └── quad-roblox/ ├── wally.toml diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index ae11dba..3f69c1a 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -337,7 +337,7 @@ end 프레임워크가 자동으로 `SetAttribute(name,nil)`을 대신 불러주지 않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`가 Destroy와 무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라", - `bind-system-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은 + `ref-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은 "Tag와 동일하게 확실히 청소"였으나 뒤집힘** — 이유: (1) diff로 조용히 빠지는 이름은 안 지워주면서 통째 소멸일 땐 지워주면, 두 경우가 서로 다른 규칙이 되어 오히려 모호해짐(사용자 지적: "diff 쌓인 거랑 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 4e350bf..c263de9 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -1,5 +1,26 @@ # Bind 시스템 — pluggable key/value 핸들러 (base로 승격됨) +> **📄 [2026-08-13 여덟 번째 세션] 이 문서는 분할 중입니다 — 1단계 완료.** +> 2989줄까지 불어나 사람이 검토할 수 없고 한 곳의 실수가 미치는 범위가 +> 너무 크다는 사용자 지적으로 쪼개는 중. **1단계로 분리된 것(내용/결정은 +> 하나도 안 바뀜, 순수 이동)**: +> +> | 나간 것 | 어디로 | +> |---|---| +> | `Ref`/`PreRef` 전체 | `base/ref-plan.md` | +> | 이벤트 바인딩(self 미전달, `false`로 disconnect) | `base/event-plan.md` | +> | `Brand`(런타임 nominal 판별) | `base/brand-plan.md` | +> +> **2단계(예정, 0-Z 반영과 같은 패스에서 할 것)**: 아직 여기 남아있는 +> **디스패치 코어**(핸들러 계약 / 확정된 디스패치 모델 / Dispatch 체인 / +> Handler 작성 체크리스트 / Length/Offset, ~1000줄)와 **반응형 코어** +> (`:With`+`:Compute` / Store·State·Source 온톨로지, ~950줄)를 각각 +> 별도 문서로. **지금 안 쪼갠 이유**: 디스패치 코어는 0-Z 확정 시 +> 어차피 전면 재작성 대상이라, 지금 옮기면 같은 텍스트를 두 번 만지고 +> 인바운드 참조(~37곳)도 두 번 고쳐야 함 — 재작성하는 그 패스에서 파일을 +> 가르는 게 총 변경량과 실수 위험이 모두 작음. 1단계가 인바운드 참조 +> 12곳으로 끝난 것과 대조됨. + > **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 > 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 > `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 @@ -1125,617 +1146,21 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 의미가 "조용히 UB"도 "즉시 실패"도 아니라 "그냥 정상적으로 동작함"으로 다시 한번 바뀜. -## Ref — 도입 확정, 단 용도는 재정의됨 +## Ref / PreRef — 전용 문서로 분리됨 (2026-08-13 여덟 번째 세션) -**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을 -실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상 -Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, `research/ -tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: +`Ref`/`PreRef`(용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API, +`Ref`의 retract, 이중 바인딩 금지, PreRef 호이스팅/1회용 가드)는 +**`base/ref-plan.md`로 분리**됨 — 이 문서가 3000줄에 육박해 분할한 +1단계. 내용/결정은 안 바뀜. -- v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정 - (`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유. -- 하지만 **"라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게"** - 하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로 - 관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을 - `Parent`로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가 - 하는 다양한 용도. -- Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 - 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 - 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). - **권장 관례(2026-08-12, use-after-destroy 검토에서 명문화):** Ref는 - 이를 만든 컴포넌트 자신이 쓰거나 자식에게 넘겨 쓰는 용도가 관례 — - React `useRef`와 같은 스코프 감각. 컴포넌트 경계를 넘어 위로 - 반출하거나 전역에 장기 보관하는 건 권장하지 않음 — Ref는 Destroy와 - 완전히 무관하게 동작하므로(아래 "Destroy와는 무관" 절), 관례를 벗어난 - 반출·장기보관은 use-after-destroy가 발생할 수 있는 사실상 유일한 - 자리가 됨. quad는 이 케이스에 런타임 안전망을 두지 않기로 확정 - (`research/framework-comparison-findings.md` 3번 절 근거) — 대응은 - 이 관례를 지키는 것뿐, 위반 시 결과는 완전한 UB. -- **바인드 방법**: 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` - 의존성 배열 같은 것도 없음. 즉 값이 채워지는 시점을 외부에서 알아낼 - 방법이 콜백(또는 폴링, 채택 안 함 — `lifecycle-pattern.md`에서 폴링 - 방식은 이미 기각된 패턴) 말고 없음. 값 자체를 나중에 다루고 싶으면 - 콜백 안에서 원하는 곳(외부 변수, `self._button` 같은 필드, Store 등)에 - 직접 대입해 캡쳐하면 됨 — `component-composition-plan.md` 31행 예제 - 참고. 즉 "값으로도 얻어진다"는 요구는 별도 API가 아니라 콜백이 이미 - 충족함. +## 이벤트 바인딩 — 전용 문서로 분리됨 (2026-08-13 여덟 번째 세션) -### Ref 일반화 — 엔진 instance 전용이 아니라 범용 값 박스 (2026-08-06 후속 세션) +"이벤트 핸들러는 self(Instance)를 받지 않는다"와 "이벤트도 store-bind +가능 — `false`로 disconnect" 두 절은 **`base/event-plan.md`로 분리**됨 +(사용자가 직접 지목한 분할 대상). 내용/결정은 안 바뀜. -**결정**: 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`(읽기 전용 필드) + `: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`로 소진** — 아래 구현 디테일 참고, - **[재정정, 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()`로 - **자기 자신을 정지**시킴(값이 채워지면 재개). 명시적으로 다른 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`**로 소진(아래 "왜 `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 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 - 둠. -- **제네릭 시그니처(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가 새로 감수하는 - 트레이드오프 아님. -- **children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계**: - 이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 `Ref` - 인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든 - instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 - 기반 + `PreRef` 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 - 표현, `PreRef` 신설" 절이 최신. -- **해소됨 — 반복 재설정 가능(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`)를 쓸 것 - — 둘을 섞으려 하지 말 것. -- **[해소됨, 2026-08-08 다섯 번째 세션]** 위 정의 확장을 감안해도 `Ref` - 이름은 그대로 확정 — "지연 없는 확정된 값 박스"라는 정의가 leaf로 - 담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이 - 없다는 결론, 용어 정리 대상에서 제외됨. - -### `Ref`의 retract — `State` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용) - -**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게 -들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State`가 실제로 -가능하고, 그러면 Store 값이 `refA`에서 `refB`로 바뀌는 경우가 생김. 이때 -`refA`가 계속 "확정된 값(대개 이전 `inst`)"을 들고 있으면, 그 자리가 이제 -`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는 -조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제. - -**메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담 -(2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째 세션에 클로저 -반환 계약으로 서술 갱신).** `Dispatch.retractFrom`은 store 값이 바뀔 -때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치 -모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 이전 -`process`가 반환한 클로저가 `hintValue=refB`로 먼저 불려 `refA`를 -언바인딩하고, 그 다음 `process(inst,k,refB,index)`가 `refB`를 바인딩하는 -두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할 -필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔 -여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지 -skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데, -그건 인자로 안 들어오고(클로저의 `hintValue`는 다음 값이지 이전 값이 -아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음(위 "핸들러 -내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례): - -```lua -local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 — - -- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가) - -RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) - -function RefLeafHandler.process(inst, k, v, index) - local old = relate:GetStrong(inst, k) - if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip - v:Set(inst) - end - relate:SetStrong(inst, k, v) - return function(hintValue) - -- hintValue는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — v는 - -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요) - if hintValue ~= v then - v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — - -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 - -- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야 - -- 함 — 밖에 두면 spurious 재발행(hintValue == v)에서도 기록이 - -- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고 - -- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이 - -- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. - if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end - end - end -end -``` - -- **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff - 로직이 없음. `hintValue == v`(같은 Ref 객체가 스스로 재발행된 - spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 - 불리게 함. -- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 - 이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로) - 아예 없고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로 - `v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 - 구현으로 자연히 커버됨, 케이스 분기 불필요. -- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째 - 세션)** — `Ref`는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을 - 여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 `Ref`(T가 - non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(`old:Set(nil)`)이 - 실제로 발생하는 자리는 **Store/Modifier 필드에 놓여 재바인드/retract가 - 가능한 `Ref`뿐**이므로, 그 자리에 놓을 `Ref`는 **호출자가 직접 - `Ref<>(...)`로 명시**할 것 — 이미 있는 "초기값이 `nil`이면 명시적 - 제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를 - 그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로 - 감지해 넓혀주지 않음 — non-nilable `T`로 선언해놓고 Store/Modifier - 자리에 놓으면 런타임에 `.Value`가 타입과 어긋나게 될 수 있는 caller - 책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결). -- **Destroy와는 무관 — 별도 처리 없음(사용자 확정).** `Ref`의 언바인딩은 - 오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가 - `Destroy()`되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도 - 않음. `Ref`가 이미 Destroy된 Frame을 계속 들고 있는 채로 남는 건 - 정상적으로 가능하고, 그 이후 읽고 쓰는 건 그냥 UB(라이브러리가 방어 - 안 함 — `:Wait(thread)`에 이미 죽은 thread를 넘기는 기존 UB와 같은 결). - Destroy 시점에 실제로 정리가 필요하면 `Effect`(내부적으로 `bindLifetime`/ - `Observer` 위에서 동작, 또는 Roblox가 Destroy 시 알아서 `Disconnect`해주는 - 이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref - 자신에 Destroy-awareness를 얹는 건 오버엔지니어링. - -### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 -세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 -아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) - -**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 -타이밍을 고르게 하던 것 자체를 없앤다.** 위 "확정된 디스패치 모델" 절에 -새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) -덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 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`/`: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 후 해시 파트로 - 존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가 - Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이 - 인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 — - 허용해도 얻는 유스케이스가 없는 오버엔지니어링. - - Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 - 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 - 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 - 없음 — `State`를 막기로 한 것(`modifier-plan.md` 7번, - 2026-08-09 세션부터 `isModifier` 기반 명시적 error)과 정확히 같은 - 원칙의 재적용. -- **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 - `Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 - children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 - 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 - 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, - 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 - 마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가 - 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 - base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의 - 작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고, - 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 - 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 - 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 - 앞에 얹히는 것. - - **복수 `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`이 아님, 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`, 배열 파트 전용 - 특수 취급). **해시 파트의 `None`(Modifier 필드 명시적 지우기)과는 - 의미가 다름** — 해시 파트 `None`은 "이 프로퍼티 핸들러야, 방금 - 지워진 걸 알고 알아서 처리해"라는 의미 있는 재디스패치(`NoneHandler` - 경유)지만, 배열 파트 `None`은 그냥 "여기 원래 아무것도 없었던 것과 - 같다"는 순수 빈 슬롯 표시라 처리할 핸들러 자체가 없음 — 재디스패치 - 없이 루프가 직접 스킵하는 게 맞음. "호이스팅"은 PreRef를 배열의 맨 - 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 - 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 - 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 - fire됨. **PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성 - 요건인 이유**: 아래 "동적 경로 가드" Handler가 `(v=PreRef)`를 - 매치하면 무조건 `error`를 던지므로, pre-pass가 슬롯을 안 지우면 - 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 - `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 세 - 번째 세션). 같은 세션에서 사용자가 직접 `{[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`/`Overridden`/버그로 타입을 우회해 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`. - - **PreRef는 "취소"라는 개념이 없다 — 1회용, 재사용은 즉시 error - (2026-08-12 여섯 번째 세션, 사용자 제안 채택).** `Ref`가 "다른 값으로 - 교체되면 `retract`로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상 - 우선순위 스캔의 `(inst,k)` 디스패치 체인에 실제로 참여해서임 — - `Dispatch.retractFrom`이 그 체인을 대상으로 동작함. `PreRef`는 애초에 - 그 체인에 올라간 적이 없음(pre-pass에서 fire와 동시에 `None`으로 - 소진되고 정상 두 패스는 건드리지 않음, 위 "호이스팅의 실제 구현" 절) — - 그래서 "취소 가능 여부" 자체가 성립할 토대가 없었던 게 구조적으로 - 이미 사실이었음, 이번 세션은 그걸 명문화한 것뿐. 진짜 위험은 취소가 - 아니라 **재사용**: 이미 한 번 fire된 `PreRef` 객체를 두 번째 - construction의 children 배열에 다시 놓으면, 거기서 등록하는 - `:Callback(fn)`이 "이미 채워져 있으면 즉시 1회 호출"이라는 규칙(위 - "Ref 일반화" 절) 때문에 **의도한 새 인스턴스가 아니라 첫 번째 fire - 때 남은 stale `.Value`로 조용히 호출**됨 — 에러도 안 나고 엉뚱한 - 값을 들고 실행되는, 디버깅하기 아주 어려운 버그. `State`/`:With`를 - "clone 빌더가 아니라 매번 새 노드"로 확정했던 원칙(2026-08-07 세 - 번째 세션, "`:With`도 새 State 노드")과 같은 클래스의 문제이자 같은 - 해법. - - **구현**: pre-pass가 첫 fire 때 해당 `PreRef` 객체에 내부 플래그 - (`_fired = true`)를 세팅. pre-pass가 배열을 훑다 `isPreRef(v)`인 - 슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서 - 즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인 - PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드" - Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는 - pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과. - - **관용구**: `Slot:List`의 `updateFn`처럼 반복 호출되는 자리에서 - `PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에 - 캡처해 여러 construction에 걸쳐 재사용하지 말 것. (참고: `Slot` - 자체는 요소 타입으로 `Ref`/`PreRef`를 이미 금지하고 있어(위 - "요소 타입 제약" 절, `slot-plan.md`) 이 관용구가 실제로 문제되는 - 자리는 `updateFn` 안에서 호출하는 컴포넌트 함수 내부뿐임.) -- **일반 `Ref`는 계속 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`로 이벤트 대상 -Instance를 넘겨주는 것, `.claude/reference/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 관리가 애초에 - 불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 - 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 - 지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`로 - disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 - 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 - 메커니즘 불필요). - -**일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — -"엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 -호출해줘도 무방하다"는 것. 다만 이벤트 등록 자체가 quad-roblox에만 -있는 개념이라(다른 백엔드는 이벤트 모델이 다를 수 있음) 이건 base -문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 -참고할 만한 템플릿 정도로만 취급. - -## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션) - -**결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 -바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. - -**엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable -바인드를 재실행하는 래핑"(위 절, 핸들러의 `process`가 값이 바뀔 때마다 -`Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 -같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. -이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을 -`process`의 로컬 변수로 들고, 반환하는 retract 클로저가 그걸 upvalue로 -캡처해 `:Disconnect()`하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존 -계약(`isHandlable`/`priority`/`process`)만 제대로 구현하면 됨(**[2026-08-13 -다섯 번째 세션]** 예전엔 별도 `retract` 필드 + per-instance `Relate` -저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐). - -**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예 -없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는 -신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤 -타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면 -`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함. -이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 -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`, 포지셔널 인자 지양 @@ -2818,142 +2243,11 @@ vs `[BooleanAttribute "name"]`)뿐 아니라 `None`/`process`/`retract` 동작 동형)가 추가되며, 단일 키 생성자는 이름 충돌 방지로 `AttributeKey<>`로 리네임됨. -## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션) +## `Brand` — 전용 문서로 분리됨 (2026-08-13 여덟 번째 세션) -**배경**: `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`에서 "이 값이 -Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서 -"이 값이 Tween인가" 판별 — 2026-08-10 세션부터 `isTween`은 `isHandlable`이 -아니라 값-레벨 분기에서만 쓰임, `base/tween-plan.md` 참고)과 사용자 -코드 양쪽에서 반복적으로 필요해질 수단이라 `isState` 하나만 만들고 -끝내지 않고 전체를 일관된 메커니즘으로 -통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 -(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). - -**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 -사용(문자열 아님).** - -``` -local Brand = {} -local registry = setmetatable({}, {__mode = "k"}) - -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, RefTag, PreRefTag, ModifierTag = - {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} - --- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: -Brand.set(newHandle, ObserverTag) -``` - -**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** — -Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 -무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열 -리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 -레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 -되어 헷갈릴 여지가 없음. - -**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — -단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 -관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우 -(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 -"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을 -"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로 -재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지 -(포함 관계의 방향)가 코드 모양 자체에 드러나게 함: - -``` -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`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 -뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만 -염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을 -가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는 -원천인가"를 알아야 하는 코드는 `isState`만으론 부족함 — `isSource`를 -별도로 제공, `isState`는 여전히 `{State, Source}` 둘 다 통과시킴(상위 -개념이니까 당연히). `component-composition-plan.md` 4번 절이 이미 -`isSource`가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두 -문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource` -불필요" 서술도 같이 정정 대상. - -**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 -추가(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`인 단순 항등 -predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX` -wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 -"핸들러 계층 값이 필드로 들어오면 즉시 error" 절이 필요로 해서 이번에 -같이 적음. - -**`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 등)이라 — 우연히 비슷한 모양의 필드/메소드를 -가진 `T`에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키 -인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한 -엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. -weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 -같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — 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`에 반영.** +런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 +10종 branded 타입 전부로 일반화)은 **`base/brand-plan.md`로 분리**됨 — +이 문서가 3000줄에 육박해 분할한 1단계. 내용/결정은 안 바뀜. ## 남은 열린 질문 (`.claude/question.md`에도 취합) diff --git a/.claude/base/brand-plan.md b/.claude/base/brand-plan.md new file mode 100644 index 0000000..6250467 --- /dev/null +++ b/.claude/base/brand-plan.md @@ -0,0 +1,146 @@ +# `Brand` — 런타임 nominal 타입 판별 통합 메커니즘 + +> **[2026-08-13 여덟 번째 세션] `bind-system-plan.md`에서 분리됨.** +> 자기 완결적인 유틸이라 디스패치 코어와 같은 파일에 있을 이유가 없었음. +> **내용은 옮기기만 했고 결정은 하나도 안 바뀜.** + +**상태**: base — 동작/구현 방식은 확정, **이름 `Brand` 자체만 용어 정리 +대기**(`question.md` 1번). + +## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션) + +**배경**: `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`에서 "이 값이 +Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서 +"이 값이 Tween인가" 판별 — 2026-08-10 세션부터 `isTween`은 `isHandlable`이 +아니라 값-레벨 분기에서만 쓰임, `base/tween-plan.md` 참고)과 사용자 +코드 양쪽에서 반복적으로 필요해질 수단이라 `isState` 하나만 만들고 +끝내지 않고 전체를 일관된 메커니즘으로 +통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 +(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). + +**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 +사용(문자열 아님).** + +``` +local Brand = {} +local registry = setmetatable({}, {__mode = "k"}) + +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, RefTag, PreRefTag, ModifierTag = + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + +-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: +Brand.set(newHandle, ObserverTag) +``` + +**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** — +Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 +무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열 +리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 +레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 +되어 헷갈릴 여지가 없음. + +**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — +단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 +관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우 +(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 +"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을 +"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로 +재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지 +(포함 관계의 방향)가 코드 모양 자체에 드러나게 함: + +``` +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`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 +뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만 +염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을 +가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는 +원천인가"를 알아야 하는 코드는 `isState`만으론 부족함 — `isSource`를 +별도로 제공, `isState`는 여전히 `{State, Source}` 둘 다 통과시킴(상위 +개념이니까 당연히). `component-composition-plan.md` 4번 절이 이미 +`isSource`가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두 +문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource` +불필요" 서술도 같이 정정 대상. + +**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 +추가(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`인 단순 항등 +predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX` +wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 +"핸들러 계층 값이 필드로 들어오면 즉시 error" 절이 필요로 해서 이번에 +같이 적음. + +**`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 등)이라 — 우연히 비슷한 모양의 필드/메소드를 +가진 `T`에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키 +인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한 +엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. +weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 +같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — 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`에 반영.** + diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 791b9d4..8fe1c8d 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -224,7 +224,7 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴 버그 실측 후 확정).** caller가 `props.Modifier`/`props.Ref`를 안 넘기면 `nil`인데, `{nil, props.Ref, child}`처럼 Lua 배열 리터럴에 `nil`이 그대로 들어가면 그 순간 테이블의 배열 파트 전체가 순회 순서 보장을 잃을 위험이 -있음(`base/bind-system-plan.md`의 PreRef pre-pass 절이 다루는 것과 같은 +있음(`base/ref-plan.md`의 PreRef pre-pass 절이 다루는 것과 같은 부류의 문제 — Luau REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 테이블 전체에 영향. **[주의]** 순서가 안 중요한 Ref 자신의 콜백/대기자 배열은 반대로 `nil` 소진이 맞다는 정정이 따로 있음 — 여기서 다루는 건 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index c5059f2..9b39199 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -154,7 +154,7 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` 플래그, 2026-08-09 세션에서 이름 확정)은 - `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정, + `base/ref-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정, 2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`가 아니라 `unbindLifetime(inst, value)`** — leaf 부착 자체가 내부적으로 `bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime` diff --git a/.claude/base/event-plan.md b/.claude/base/event-plan.md new file mode 100644 index 0000000..4afa3b7 --- /dev/null +++ b/.claude/base/event-plan.md @@ -0,0 +1,118 @@ +# 이벤트 바인딩 — self 미전달, `false`로 disconnect + +> **[2026-08-13 여덟 번째 세션] `bind-system-plan.md`에서 분리됨.** +> 사용자가 "이벤트 연결은 다른 base 문서가 되어야 할 듯"이라고 직접 +> 지목한 부분. **내용은 옮기기만 했고 결정은 하나도 안 바뀜.** + +**상태**: base — 확정. + +**여기 없는 이벤트 관련 결정 하나**: 이벤트 *네이밍* 인체공학 +(`On.EventName` 도트액세스를 안 쓰고 평범한 문자열 키 + reflection을 +쓰기로 한 것)은 인스턴스 생성 관례와 한 절에 섞여 있어 +`base/bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" +절에 그대로 뒀음 — 쪼개면 인스턴스 생성 쪽 서술이 반토막 나서. +`GetPropertyChangedSignal` 바인딩은 별도 문서 `base/onchange-plan.md`. + +## 이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06) + +**결정**: v1의 `function(self, ...)` 관습(`self`/`this`로 이벤트 대상 +Instance를 넘겨주는 것, `.claude/reference/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 관리가 애초에 + 불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 + 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 + 지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`로 + disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 + 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 + 메커니즘 불필요). + +**일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — +"엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 +호출해줘도 무방하다"는 것. 다만 이벤트 등록 자체가 quad-roblox에만 +있는 개념이라(다른 백엔드는 이벤트 모델이 다를 수 있음) 이건 base +문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 +참고할 만한 템플릿 정도로만 취급. + +## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션) + +**결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 +바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. + +**엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable +바인드를 재실행하는 래핑"(위 절, 핸들러의 `process`가 값이 바뀔 때마다 +`Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 +같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. +이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을 +`process`의 로컬 변수로 들고, 반환하는 retract 클로저가 그걸 upvalue로 +캡처해 `:Disconnect()`하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존 +계약(`isHandlable`/`priority`/`process`)만 제대로 구현하면 됨(**[2026-08-13 +다섯 번째 세션]** 예전엔 별도 `retract` 필드 + per-instance `Relate` +저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐). + +**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예 +없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는 +신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤 +타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면 +`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함. +이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 +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번 "권장 이벤트 핸들링 패턴" 문서에 이 대조까지 반영 예정). + diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index c7fb41e..f23341c 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -33,7 +33,7 @@ cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드 **flatten이 배열 항목 중 뭐가 Modifier인지 판별하는 수단 — `isModifier` (`Brand` 기반, 2026-08-07 열 번째 세션 명시).** 다른 모든 nominal 타입 -판별과 같은 메커니즘(`bind-system-plan.md`의 `Brand` 절) 재사용 — flatten은 +판별과 같은 메커니즘(`brand-plan.md`의 `Brand` 절) 재사용 — flatten은 배열을 훑으며 `isModifier(v)`가 참인 항목만 필드를 뽑아 merge하고, 나머지는 전혀 안 건드리고 그대로 배열 파트에 남겨둠(그래서 `None`처럼 Modifier가 아닌 값은 flatten을 그냥 통과함 — `component-composition-plan.md`의 @@ -79,7 +79,7 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 **결론 — `None`은 raw 저장 계층에만 존재하는 실재값 센티널, merge/setter는 전혀 안 바뀜.** 이벤트 store-bind의 "`nil` 대신 실재하는 센티널" -(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 store-bind +(`false`로 disconnect, `base/event-plan.md` "이벤트도 store-bind 가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라 **디스패치 단계**에서 풀린다: @@ -600,7 +600,7 @@ setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방 Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSource`도 별도로 존재함**, `:Set`/`:Emit` 같은 Source 전용 능력이 있는지 알아야 하는 코드를 위해 필요하다고 판단 정정됨). 상세 근거/구현 방식은 -`bind-system-plan.md`의 `Brand`/`isState` 절 참고 — 요지만: duck-typing +`brand-plan.md`의 `Brand`/`isState` 절 참고 — 요지만: duck-typing 대신 weak-key 레지스트리 기반(quad의 다른 branded 타입 전부와 공유하는 통합 메커니즘으로 일반화됨), 그리고 이 판별 로직 자체는 새로 만드는 게 아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md new file mode 100644 index 0000000..52cb0dc --- /dev/null +++ b/.claude/base/ref-plan.md @@ -0,0 +1,519 @@ +# Ref / PreRef — 지연 없는 확정 값 박스 + +> **[2026-08-13 여덟 번째 세션] `bind-system-plan.md`에서 분리됨.** 그 +> 문서가 2989줄까지 불어나 사람이 검토하기 어렵고 한 곳의 실수가 미치는 +> 범위가 너무 커진다는 사용자 지적에 따른 1단계 분할. **내용은 옮기기만 +> 했고 결정은 하나도 안 바뀜.** + +**상태**: base — 확정. `Dispatch`/`Brand`와의 관계는 +`base/bind-system-plan.md`(디스패치 코어)와 `base/brand-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번 항목) — "비현실적"이라는 게 이유. +- 하지만 **"라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게"** + 하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로 + 관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을 + `Parent`로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가 + 하는 다양한 용도. +- Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 + 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 + 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). + **권장 관례(2026-08-12, use-after-destroy 검토에서 명문화):** Ref는 + 이를 만든 컴포넌트 자신이 쓰거나 자식에게 넘겨 쓰는 용도가 관례 — + React `useRef`와 같은 스코프 감각. 컴포넌트 경계를 넘어 위로 + 반출하거나 전역에 장기 보관하는 건 권장하지 않음 — Ref는 Destroy와 + 완전히 무관하게 동작하므로(아래 "Destroy와는 무관" 절), 관례를 벗어난 + 반출·장기보관은 use-after-destroy가 발생할 수 있는 사실상 유일한 + 자리가 됨. quad는 이 케이스에 런타임 안전망을 두지 않기로 확정 + (`research/framework-comparison-findings.md` 3번 절 근거) — 대응은 + 이 관례를 지키는 것뿐, 위반 시 결과는 완전한 UB. +- **바인드 방법**: 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` + 의존성 배열 같은 것도 없음. 즉 값이 채워지는 시점을 외부에서 알아낼 + 방법이 콜백(또는 폴링, 채택 안 함 — `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`(읽기 전용 필드) + `: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`로 소진** — 아래 구현 디테일 참고, + **[재정정, 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()`로 + **자기 자신을 정지**시킴(값이 채워지면 재개). 명시적으로 다른 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`**로 소진(아래 "왜 `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 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 + 둠. +- **제네릭 시그니처(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가 새로 감수하는 + 트레이드오프 아님. +- **children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계**: + 이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 `Ref` + 인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든 + instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 + 기반 + `PreRef` 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 + 표현, `PreRef` 신설" 절이 최신. +- **해소됨 — 반복 재설정 가능(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`)를 쓸 것 + — 둘을 섞으려 하지 말 것. +- **[해소됨, 2026-08-08 다섯 번째 세션]** 위 정의 확장을 감안해도 `Ref` + 이름은 그대로 확정 — "지연 없는 확정된 값 박스"라는 정의가 leaf로 + 담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이 + 없다는 결론, 용어 정리 대상에서 제외됨. + +### `Ref`의 retract — `State` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용) + +**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게 +들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State`가 실제로 +가능하고, 그러면 Store 값이 `refA`에서 `refB`로 바뀌는 경우가 생김. 이때 +`refA`가 계속 "확정된 값(대개 이전 `inst`)"을 들고 있으면, 그 자리가 이제 +`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는 +조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제. + +**메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담 +(2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째 세션에 클로저 +반환 계약으로 서술 갱신).** `Dispatch.retractFrom`은 store 값이 바뀔 +때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치 +모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 이전 +`process`가 반환한 클로저가 `hintValue=refB`로 먼저 불려 `refA`를 +언바인딩하고, 그 다음 `process(inst,k,refB,index)`가 `refB`를 바인딩하는 +두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할 +필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔 +여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지 +skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데, +그건 인자로 안 들어오고(클로저의 `hintValue`는 다음 값이지 이전 값이 +아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음(위 "핸들러 +내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례): + +```lua +local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 — + -- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가) + +RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) + +function RefLeafHandler.process(inst, k, v, index) + local old = relate:GetStrong(inst, k) + if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip + v:Set(inst) + end + relate:SetStrong(inst, k, v) + return function(hintValue) + -- hintValue는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — v는 + -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요) + if hintValue ~= v then + v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — + -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 + -- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야 + -- 함 — 밖에 두면 spurious 재발행(hintValue == v)에서도 기록이 + -- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고 + -- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이 + -- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. + if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end + end + end +end +``` + +- **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff + 로직이 없음. `hintValue == v`(같은 Ref 객체가 스스로 재발행된 + spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 + 불리게 함. +- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 + 이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로) + 아예 없고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로 + `v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 + 구현으로 자연히 커버됨, 케이스 분기 불필요. +- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째 + 세션)** — `Ref`는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을 + 여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 `Ref`(T가 + non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(`old:Set(nil)`)이 + 실제로 발생하는 자리는 **Store/Modifier 필드에 놓여 재바인드/retract가 + 가능한 `Ref`뿐**이므로, 그 자리에 놓을 `Ref`는 **호출자가 직접 + `Ref<>(...)`로 명시**할 것 — 이미 있는 "초기값이 `nil`이면 명시적 + 제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를 + 그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로 + 감지해 넓혀주지 않음 — non-nilable `T`로 선언해놓고 Store/Modifier + 자리에 놓으면 런타임에 `.Value`가 타입과 어긋나게 될 수 있는 caller + 책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결). +- **Destroy와는 무관 — 별도 처리 없음(사용자 확정).** `Ref`의 언바인딩은 + 오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가 + `Destroy()`되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도 + 않음. `Ref`가 이미 Destroy된 Frame을 계속 들고 있는 채로 남는 건 + 정상적으로 가능하고, 그 이후 읽고 쓰는 건 그냥 UB(라이브러리가 방어 + 안 함 — `:Wait(thread)`에 이미 죽은 thread를 넘기는 기존 UB와 같은 결). + Destroy 시점에 실제로 정리가 필요하면 `Effect`(내부적으로 `bindLifetime`/ + `Observer` 위에서 동작, 또는 Roblox가 Destroy 시 알아서 `Disconnect`해주는 + 이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref + 자신에 Destroy-awareness를 얹는 건 오버엔지니어링. + +### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 +세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 +아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) + +**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 +타이밍을 고르게 하던 것 자체를 없앤다.** 위 "확정된 디스패치 모델" 절에 +새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) +덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 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`/`: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 후 해시 파트로 + 존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가 + Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이 + 인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 — + 허용해도 얻는 유스케이스가 없는 오버엔지니어링. + - Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 + 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 + 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 + 없음 — `State`를 막기로 한 것(`modifier-plan.md` 7번, + 2026-08-09 세션부터 `isModifier` 기반 명시적 error)과 정확히 같은 + 원칙의 재적용. +- **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 + `Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 + children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 + 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 + 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, + 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 + 마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가 + 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 + base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의 + 작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고, + 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 + 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 + 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 + 앞에 얹히는 것. + - **복수 `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`이 아님, 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`, 배열 파트 전용 + 특수 취급). **해시 파트의 `None`(Modifier 필드 명시적 지우기)과는 + 의미가 다름** — 해시 파트 `None`은 "이 프로퍼티 핸들러야, 방금 + 지워진 걸 알고 알아서 처리해"라는 의미 있는 재디스패치(`NoneHandler` + 경유)지만, 배열 파트 `None`은 그냥 "여기 원래 아무것도 없었던 것과 + 같다"는 순수 빈 슬롯 표시라 처리할 핸들러 자체가 없음 — 재디스패치 + 없이 루프가 직접 스킵하는 게 맞음. "호이스팅"은 PreRef를 배열의 맨 + 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 + 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 + 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 + fire됨. **PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성 + 요건인 이유**: 아래 "동적 경로 가드" Handler가 `(v=PreRef)`를 + 매치하면 무조건 `error`를 던지므로, pre-pass가 슬롯을 안 지우면 + 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 + `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 세 + 번째 세션). 같은 세션에서 사용자가 직접 `{[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`/`Overridden`/버그로 타입을 우회해 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`. + - **PreRef는 "취소"라는 개념이 없다 — 1회용, 재사용은 즉시 error + (2026-08-12 여섯 번째 세션, 사용자 제안 채택).** `Ref`가 "다른 값으로 + 교체되면 `retract`로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상 + 우선순위 스캔의 `(inst,k)` 디스패치 체인에 실제로 참여해서임 — + `Dispatch.retractFrom`이 그 체인을 대상으로 동작함. `PreRef`는 애초에 + 그 체인에 올라간 적이 없음(pre-pass에서 fire와 동시에 `None`으로 + 소진되고 정상 두 패스는 건드리지 않음, 위 "호이스팅의 실제 구현" 절) — + 그래서 "취소 가능 여부" 자체가 성립할 토대가 없었던 게 구조적으로 + 이미 사실이었음, 이번 세션은 그걸 명문화한 것뿐. 진짜 위험은 취소가 + 아니라 **재사용**: 이미 한 번 fire된 `PreRef` 객체를 두 번째 + construction의 children 배열에 다시 놓으면, 거기서 등록하는 + `:Callback(fn)`이 "이미 채워져 있으면 즉시 1회 호출"이라는 규칙(위 + "Ref 일반화" 절) 때문에 **의도한 새 인스턴스가 아니라 첫 번째 fire + 때 남은 stale `.Value`로 조용히 호출**됨 — 에러도 안 나고 엉뚱한 + 값을 들고 실행되는, 디버깅하기 아주 어려운 버그. `State`/`:With`를 + "clone 빌더가 아니라 매번 새 노드"로 확정했던 원칙(2026-08-07 세 + 번째 세션, "`:With`도 새 State 노드")과 같은 클래스의 문제이자 같은 + 해법. + - **구현**: pre-pass가 첫 fire 때 해당 `PreRef` 객체에 내부 플래그 + (`_fired = true`)를 세팅. pre-pass가 배열을 훑다 `isPreRef(v)`인 + 슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서 + 즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인 + PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드" + Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는 + pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과. + - **관용구**: `Slot:List`의 `updateFn`처럼 반복 호출되는 자리에서 + `PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에 + 캡처해 여러 construction에 걸쳐 재사용하지 말 것. (참고: `Slot` + 자체는 요소 타입으로 `Ref`/`PreRef`를 이미 금지하고 있어(위 + "요소 타입 제약" 절, `slot-plan.md`) 이 관용구가 실제로 문제되는 + 자리는 `updateFn` 안에서 호출하는 컴포넌트 함수 내부뿐임.) +- **일반 `Ref`는 계속 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` 후보로 메모). + diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 511e8ad..c964def 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -32,9 +32,10 @@ `.claude/base/`와 `ROADMAP.md` M0가 "추론만으로 확정하고 실제 Luau 코드로 부딪혀본 적 없는 것"으로 명시적으로 지목한 항목들, 그리고 이후 세션들에서 "M0/M2 스파이크 검증 목록에 추가됨"으로 흩어져 있던 항목들을 모아 각각 -독립 실행 가능한 스크립트로 만들었음. **내가(에이전트) 직접 실행은 못 -했음** — 이 환경엔 `luau`/`luau-analyze` 바이너리가 없어서, 전부 사용자가 -직접 돌려보고 결과를 알려줘야 함. +독립 실행 가능한 스크립트로 만들었음. **[2026-08-13 갱신]** 처음엔 이 +환경에 `luau`/`luau-analyze` 바이너리가 없어 에이전트가 못 돌렸으나, +여섯 번째 세션에 바이너리가 생겨 **첫 실측이 끝남** — 지금은 에이전트가 +직접 돌릴 수 있고, 사용자 손이 필요한 건 Studio 전용(`not-run/`)뿐. 각 파일 맨 위 주석에 다음이 전부 적혀있음: 뭘 검증하는지, 어느 base 문서/ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해야 하는지. @@ -75,10 +76,10 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | | `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 왕복 — **[2026-08-13]** A 섹션 앞부분(신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됨, `audit/gcconn-trick-verification.md` 참고. A-1/A-2(`canBound` 게이트)/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | +| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 — **[2026-08-13]** A 섹션 앞부분(신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됨, `audit/gcconn-trick-verification.md` 참고. A-1/A-2(`canBound` 게이트)/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md`, `ref-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` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 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 열한 번째 세션 재정정) | +| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `brand-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 열한 번째 세션]" 절 | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | | `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | diff --git a/.claude/question.md b/.claude/question.md index 91199e4..d5c8218 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -288,11 +288,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** `base/slot-plan.md` "`Slot:List(...)`" 절. - **[해소됨, 2026-08-09 세션]** `Bound` — **`canBound(handle): boolean` 탑레벨 함수로 확정**, `canExecute`와 같은 결(raw 필드를 직접 노출하는 - 대신 predicate 함수로 감쌈). `base/bind-system-plan.md` "이중 바인딩 + 대신 predicate 함수로 감쌈). `base/ref-plan.md` "이중 바인딩 금지" 절 참고. - **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 - 10종 branded 타입 전부로 일반화) — `bind-system-plan.md`의 `Brand` + 10종 branded 타입 전부로 일반화) — `brand-plan.md`의 `Brand` 절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을 전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`는 이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로 diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index b329305..7547a9e 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -499,7 +499,7 @@ Tween mock 등 동적 동작 포함")와 목적이 다름: thin wrapper를 준다면 Modifier 정적 flatten과 경쟁하는 두 번째 쓰기 경로가 생겨 오버엔지니어링/성능/디버깅 추적성 문제가 생긴다는 게 이유 — 클로저 래핑 비용도 근거로 추가됨. 상세 근거와 결정문은 - `base/bind-system-plan.md`의 "이벤트 핸들러는 self(Instance)를 받지 + `base/event-plan.md`의 "이벤트 핸들러는 self(Instance)를 받지 않는다" 절 참고. quad-debug 입장에서는 self/thin wrapper 경로가 아예 없어지므로 오히려 계측 대상이 단순해짐(추적 안 되는 경로 자체가 존재하지 않게 됨). diff --git a/.claude/research/dispatch-redispatch-diff-plan.md b/.claude/research/dispatch-redispatch-diff-plan.md index 3f9e5dd..1033840 100644 --- a/.claude/research/dispatch-redispatch-diff-plan.md +++ b/.claude/research/dispatch-redispatch-diff-plan.md @@ -252,6 +252,13 @@ Dispatch.process(inst, k, realv, index + 1) -- retractFrom 선행 호출 없 반환해야 함")이 선행 `retractFrom` 전제로 쓰여 있음 — base 4개를 옮길 때 같이 갱신하고 배너를 걷을 것. +- **[2026-08-13 8차 감사에서 추가] 같은 패스에서 `bind-system-plan.md` + 2단계 분할도 할 것** — 이 문서가 지시하는 재작성 범위(핸들러 계약 / + 확정된 디스패치 모델 / Dispatch 체인 / Handler 작성 체크리스트)가 곧 + 분할 예정인 "디스패치 코어" 블록과 정확히 겹침. 어차피 그 텍스트를 + 전면 재작성하므로, **재작성하면서 새 파일로 옮기면 인바운드 참조 + (~37곳)를 한 번만 고치면 됨.** 따로 하면 같은 곳을 두 번 만짐. + **요약**: 배너를 달고 있는 파일 = 반영 대상. 위 6개(`bind-system-plan`/ `tag-plan`/`slot-plan`/`attribute-plan`/`architecture`/`ROADMAP`)가 전부이고, 반영이 끝나면 각 파일의 ⚠️ 배너도 같이 제거할 것. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index c515376..73bb3a8 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -209,7 +209,7 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: 개념 자체가 다름)에 가깝다는 구분 — quadnomicon 에세이로 쓸 때 이 "hook"/"pre-hook" 용어 자체를 채택할지만 아직 열려있음(복수 `PreRef` 간 순서는 2026-08-07 아홉 번째 세션에서 해소됨 — 배열 index 순서 - 그대로, 별도 규칙 없음, `bind-system-plan.md` "PreRef" 절 참고). + 그대로, 별도 규칙 없음, `ref-plan.md` "PreRef" 절 참고). **[해소됨, 2026-08-12 여섯 번째 세션]** 취소 가능 여부 — PreRef는 구조적으로 `retract` 체인에 아예 안 올라가므로 취소 개념 자체가 없고, 대신 이미 fire된 PreRef를 재사용하면(두 번째 construction에 다시 diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md index c8c7b07..47f525c 100644 --- a/.claude/research/documentation-plan.md +++ b/.claude/research/documentation-plan.md @@ -131,7 +131,7 @@ UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같 ## 3. 권장 이벤트 핸들링 패턴 문서 (2026-08-06 추가) **배경**: 이벤트 핸들러가 self(Instance)를 받지 않기로 확정된 결정 -(`base/bind-system-plan.md` "이벤트 핸들러는 self(Instance)를 받지 +(`base/event-plan.md` "이벤트 핸들러는 self(Instance)를 받지 않는다" 절)의 근거가 꽤 비직관적임 — v1을 써본 사용자는 self가 없어진 걸 보고 "왜 없어졌지, 불편한데"라고 느낄 수 있어서 근거까지 같이 문서화할 가치가 있음. @@ -151,7 +151,7 @@ UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같 - **조건부 이벤트 처리 — 기본 패턴 vs store-bind, 대조해서 보여줄 것** (2026-08-06 추가). "핸들러 하나 계속 연결 + 내부에서 `store.enabled:Get()` 분기"가 기본 권장 패턴이고, 이벤트 store-bind(`false`로 disconnect, - `bind-system-plan.md` "이벤트도 store-bind 가능" 절)는 고빈도 신호나 + `event-plan.md` "이벤트도 store-bind 가능" 절)는 고빈도 신호나 로직 자체가 바뀌는 드문 케이스를 위한 부차적 옵션이라는 점을 코드 대조로 보여줄 것 — quad가 미는 디자인은 전자, 후자는 "이럴 수도 있다" 정도로만 소개. diff --git a/ROADMAP.md b/ROADMAP.md index 20f1031..92bae47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `Attribute(...)` 프리미티브 신설로 같은 이름이 서로 다른 두 대상(키 vs 값)을 가리키게 돼서 갈라짐, `base/attribute-plan.md` 참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` - 항등 비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 + 항등 비교 — `brand-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째 세션 신설) - [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — `Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. @@ -232,7 +232,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도 내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/ - bind-system-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 + ref-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 이름은 2026-08-09 세션에 `canBound`로 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜 독립 경로는 둘뿐). `canBound`의 내부 플래그는 `canExecute`가 보는 @@ -483,7 +483,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) - [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 + `isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 — - `modifier-plan.md` 9번, `bind-system-plan.md`의 `Brand` 절, M2의 + `modifier-plan.md` 9번, `brand-plan.md`의 `Brand` 절, M2의 `Brand.luau`에 이미 구현돼 있어야 함) - [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널 (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + @@ -516,7 +516,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 충돌하므로 기각). 복수 `PreRef`는 배열 index 순서 그대로(별도 규칙 없음). fire된 슬롯은 그 자리에서 소진(`None` 처리, `nil` 아님 — 2026-08-07 열 번째 세션 정정)해 이어지는 정상 두 패스에 다시 노출 - 안 되게 함 — `base/bind-system-plan.md` "PreRef" 절 + 안 되게 함 — `base/ref-plan.md` "PreRef" 절 - [ ] `PreRef` 동적 경로 가드 Handler — `{isHandlable = v is PreRef, process = error(...)}` 형태로 정상 우선순위 레지스트리에 등록, `NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는