refactor(base): bind-system-plan.md 1단계 분할 — Ref/PreRef·이벤트·Brand를 별도 문서로

사용자 지적: 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6hzeUi5QdLPEk69B6cXFa
This commit is contained in:
qwreey 2026-08-13 18:06:24 +09:00
parent a30fb43d49
commit 6b73cb4208
Signed by: qwreey
GPG key ID: D28DB79297A214BD
20 changed files with 855 additions and 767 deletions

View file

@ -44,6 +44,9 @@
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `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<function>`은 기존 이벤트 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<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) |
## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)

View file

@ -34,6 +34,6 @@ predicate(핸들러가 이 키/값을 담당하는지 판단, 핸들러 계약 4
`isState`만으론 부족함 — `isSource`를 별도로 제공해야 함. `isState`
여전히 `{State, Source}` 둘 다 통과시킴(상위집합 판별 유지).
**현재 유효한 설계**: `base/bind-system-plan.md`의 `Brand`
**현재 유효한 설계**: `base/brand-plan.md`의 `Brand`
(`isState`/`isSource`가 둘 다 존재, 전자는 집합 멤버십, 후자는 단순 항등)이
최종 소스.

View file

@ -12,7 +12,7 @@
## 역전된 사례 — 원래 무엇을 확정했었나
**1. Ref는 dispatch 레지스트리의 "평범한 참가자"였음** (2026-08-04
원문, `bind-system-plan.md` "Ref — 도입 확정" 절):
원문, `ref-plan.md` "Ref — 도입 확정" 절):
> **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯
> `CreatedRef` 같은 값을 숫자 키 슬롯에 넣는 방식 — child와 동일한

View file

@ -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 바인드의 관계" 절이 최종 소스.
## 역전된 사례 — 원래 무엇을 확정했었나

View file

@ -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

View file

@ -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 쌓인 거랑

View file

@ -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<T>`(쓰기) +
`:Callback(fn) -> Ref<T>`(콜백 등록, 복수 허용) + `:Wait(thread?) -> Ref<T>`
(coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 `return` 위에 바로
못 씀, 그래서 콜백도 같이 필요) **세 메소드로 확정(2026-08-07 여섯 번째
세션)**. `:Set`/`:Callback`/`:Wait` 전부 **mutation 패턴이라 자기 자신
(`Ref<T>`)을 반환** — `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>(T) -> Ref<T>` — 단일 타입
파라미터.** React `useRef<T, U=T>(U): T|U`류 "초기값 타입과 최종 타입을
분리"하는 2파라미터 설계도 검토했으나(예: `Ref<<HTMLDivElement>>(null)`
`HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와
인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게
풀리지 않고 미해소 제네릭 변수가 결과 타입에 남는 것으로 확인(사용자가
직접 Luau 플레이그라운드류로 확인) — `Source<T> satisfies State<T>`
`State<Modifier>` 차단 검증 항목(`research/pre-implementation-audit.md`)
에서 이미 반복 확인된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다"는
패턴과 같은 결. 단일 파라미터로 단순화하면 이 위험 자체가 없음 — 대신
초기값만으로 좁은 타입이 추론되는 문제(`Ref(nil)`이 `Ref<nil>`
좁혀짐)는 `Ref<<Obj?>>(nil)`처럼 **명시적 제네릭 적용**(`f<<T>>(...)`
패턴, `.claude/initreq/tbox/CLAUDE.md:40-41` 선례)으로 타입을 넓혀
풀면 됨 — React `useRef<HTMLDivElement>(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>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용)
**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게
들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State<Ref>`가 실제로
가능하고, 그러면 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>`(T가
non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(`old:Set(nil)`)이
실제로 발생하는 자리는 **Store/Modifier 필드에 놓여 재바인드/retract가
가능한 `Ref`뿐**이므로, 그 자리에 놓을 `Ref`는 **호출자가 직접
`Ref<<T?>>(...)`로 명시**할 것 — 이미 있는 "초기값이 `nil`이면 명시적
제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를
그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로
감지해 넓혀주지 않음 — non-nilable `T`로 선언해놓고 Store/Modifier
자리에 놓으면 런타임에 `.Value`가 타입과 어긋나게 될 수 있는 caller
책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결).
- **Destroy와는 무관 — 별도 처리 없음(사용자 확정).** `Ref`의 언바인딩은
오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가
`Destroy()`되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도
않음. `Ref<Frame?>`가 이미 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>`를 막기로 한 것(`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<<T>>`
리네임됨.
## `Brand`런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션)
## `Brand`전용 문서로 분리됨 (2026-08-13 여덟 번째 세션)
**배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<<T>>(key):
T|State<T>|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<any> ... 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`에도 취합)

146
.claude/base/brand-plan.md Normal file
View file

@ -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<<T>>(key):
T|State<T>|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<any> ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 게 실제
패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만
"자동 narrowing"을 기대하면 안 됨.
**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상,
`.claude/question.md`에 반영.**

View file

@ -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` 소진이 맞다는 정정이 따로 있음 — 여기서 다루는 건

View file

@ -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`

118
.claude/base/event-plan.md Normal file
View file

@ -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번 "권장 이벤트 핸들링 패턴" 문서에 이 대조까지 반영 예정).

View file

@ -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 유틸로

519
.claude/base/ref-plan.md Normal file
View file

@ -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<T>`(쓰기) +
`:Callback(fn) -> Ref<T>`(콜백 등록, 복수 허용) + `:Wait(thread?) -> Ref<T>`
(coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 `return` 위에 바로
못 씀, 그래서 콜백도 같이 필요) **세 메소드로 확정(2026-08-07 여섯 번째
세션)**. `:Set`/`:Callback`/`:Wait` 전부 **mutation 패턴이라 자기 자신
(`Ref<T>`)을 반환** — `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>(T) -> Ref<T>` — 단일 타입
파라미터.** React `useRef<T, U=T>(U): T|U`류 "초기값 타입과 최종 타입을
분리"하는 2파라미터 설계도 검토했으나(예: `Ref<<HTMLDivElement>>(null)`
`HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와
인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게
풀리지 않고 미해소 제네릭 변수가 결과 타입에 남는 것으로 확인(사용자가
직접 Luau 플레이그라운드류로 확인) — `Source<T> satisfies State<T>`
`State<Modifier>` 차단 검증 항목(`research/pre-implementation-audit.md`)
에서 이미 반복 확인된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다"는
패턴과 같은 결. 단일 파라미터로 단순화하면 이 위험 자체가 없음 — 대신
초기값만으로 좁은 타입이 추론되는 문제(`Ref(nil)`이 `Ref<nil>`
좁혀짐)는 `Ref<<Obj?>>(nil)`처럼 **명시적 제네릭 적용**(`f<<T>>(...)`
패턴, `.claude/initreq/tbox/CLAUDE.md:40-41` 선례)으로 타입을 넓혀
풀면 됨 — React `useRef<HTMLDivElement>(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>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용)
**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게
들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State<Ref>`가 실제로
가능하고, 그러면 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>`(T가
non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(`old:Set(nil)`)이
실제로 발생하는 자리는 **Store/Modifier 필드에 놓여 재바인드/retract가
가능한 `Ref`뿐**이므로, 그 자리에 놓을 `Ref`는 **호출자가 직접
`Ref<<T?>>(...)`로 명시**할 것 — 이미 있는 "초기값이 `nil`이면 명시적
제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를
그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로
감지해 넓혀주지 않음 — non-nilable `T`로 선언해놓고 Store/Modifier
자리에 놓으면 런타임에 `.Value`가 타입과 어긋나게 될 수 있는 caller
책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결).
- **Destroy와는 무관 — 별도 처리 없음(사용자 확정).** `Ref`의 언바인딩은
오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가
`Destroy()`되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도
않음. `Ref<Frame?>`가 이미 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>`를 막기로 한 것(`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` 후보로 메모).

View file

@ -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<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 |
| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 — **[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<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (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>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 |

View file

@ -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` 래퍼로 쓰여서 이름 충돌, 후보로

View file

@ -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 경로가
아예 없어지므로 오히려 계측 대상이 단순해짐(추적 안 되는 경로 자체가
존재하지 않게 됨).

View file

@ -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`)가
전부이고, 반영이 끝나면 각 파일의 ⚠️ 배너도 같이 제거할 것.

View file

@ -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에 다시

View file

@ -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가 미는 디자인은 전자, 후자는 "이럴 수도 있다"
정도로만 소개.

View file

@ -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<<T>>(key): T|State<T>|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`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는