decide(base): Slot 형제 순서 보장(Length/Offset), bindLifetime/unbindLifetime과 canBound 이중 바인딩 게이트 통합

여러 Slot이 형제로 섞일 때 LayoutOrder 순서 보장 메커니즘을
Dispatch.setLength/setOffsetSource 누적합+리액티브 바인딩으로 확정하고
base/slot-plan.md의 관련 열린 질문을 해소. 이 과정에서 발견된 라이프사이클
게이트의 실제 모양(진짜 독립 경로는 :Subscribe()/bindLifetime 둘뿐이고
leaf 부착은 bindLifetime 호출 그 자체라는 점, canBound의 내부 플래그가
canExecute가 보는 .Subscribed와 동일 필드라는 점)을 반영해 이중 바인딩
금지 규칙과 기존 StoreBind 예제를 정정.
This commit is contained in:
qwreey 2026-08-09 20:58:56 +09:00
parent baa004ad42
commit b9cfe8e99d
Signed by: qwreey
GPG key ID: D28DB79297A214BD
7 changed files with 510 additions and 89 deletions

View file

@ -407,6 +407,142 @@ end
그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에
새로 설계할 필요 없음. 새로 설계할 필요 없음.
### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션)
**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문,
2026-08-04 신설)**: `Frame { Slot1, Element, Slot2 }`처럼 Slot과 정적
자식이 형제로 섞일 때, Slot1의 동적 개수가 바뀌어도 "Slot1 전체는 항상
Element보다 앞, Slot2보다 앞"이라는 저작 순서가 유지돼야 함. Slot2가
자기 순서를 정하려고 "Slot1이 지금 몇 개인지"를 직접 세는 방식은
Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드 의존을
만들어서 막다른 길.
**해법의 핵심 전환**: 절대 위치를 계산해서 전파하는 게 아니라, **각
구조적 위치(자리 자체는 저작 시점에 고정)가 자기 앞의 형제들이 지금까지
기여한 개수의 누적합만 알면 됨** — Roblox는 `LayoutOrder`/`ZIndex`가
`Instance.Parent` 배열의 물리적 순서와 완전히 분리된 정수 프로퍼티라,
이 누적합을 그 프로퍼티에 반응형으로 바인딩하기만 하면 별도 배선이
필요 없음(이미 있는 store-bind 재실행 패턴 재사용).
**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**:
```lua
Dispatch.setLength(inst, i, len: number | State<number>)
Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
```
- **`setLength`**: 이 위치(array part의 number 인덱스 `i`)가 지금 몇 개의
실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수
`1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State<number>`,
아래 참고), `state<Frame>`처럼 store-bind로 오가는 단일 위치는 그
store-bind 핸들러가 값이 바뀔 때마다 다시 호출.
- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source<number>`
**스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만
하고, `recompute`가 그 자리에 값을 `:Set()`함. Handler는 이 **같은**
Source 객체를 자기 원소(들)의 `LayoutOrder` 바인딩에 그대로 씀
(`localIndex:With(offset):Compute(function(i,o) return i+o end)`을
`LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind
재실행 메커니즘이 알아서 다시 씀 — 새 push/observer 시스템 불필요).
**실제 마운트를 하지 않는 위치(Ref/PreRef 등)는 `None`을 등록** — 순서
계산에 참여할 게 없다는 명시적 선언.
**둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB
(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 —
이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는
이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨.
**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그
`inst`의 array part 크기 `N`만큼) — `Relate(parentInst)`에 lazy 생성.
**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**:
```lua
local function recompute(inst, bk)
local sum = 0
for i = 1, bk.N do
local v = bk.lengthList[i]
sum += (isState(v) and v:Get() or v)
local offset = bk.sourceList[i]
if offset and offset:Get() ~= sum then -- 실제로 다를 때만 Set
offset:Set(sum)
end
end
end
```
전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴
길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브
캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라,
`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안
일어나게 막음.
**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`),
`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**:
```lua
function Dispatch.setLength(inst, i, len)
local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성
local oldObserver = bk.observers[i]
if oldObserver then
unbindLifetime(inst, oldObserver) -- gchold 내부 구조 몰라도 됨
bk.observers[i] = nil
end
bk.lengthList[i] = len
if isState(len) then
local observer = len:Observer(function()
recompute(inst, bk)
end)
bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님
bk.observers[i] = observer
end
recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해)
end
```
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때
같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안
끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native,
`inst` 생명주기에 자동 귀속)를 충족.
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:
Slot의 `rawAdd``self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder
갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제
트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set`
자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위
`Get` 가드와 같은 원칙을 호출부에서도 적용).
**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이
한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클
도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨.
**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/
`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**:
DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로,
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 —
quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고,
`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느
물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히
동일, backend Handler의 "무엇을 하는가"만 다름.
**`Slot.Length`와 `Slot.Offset`은 별개(사용자 질문으로 명시화)**:
`Length`는 Slot이 스스로 노출하는 순수 출력값(지금 실제로 마운트된
개수) — "n개 검색됨" 같은 UI에 그대로 써도 되고, 동시에 위 `setLength`
읽는 바로 그 값(하나의 State가 두 용도를 겸함). `:List`가 filter 탈락을
실제 `Remove`로 처리하도록 이미 확정해둔 덕에(Visible 토글 아님) `Length`
자동으로 "실제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰는 경우엔
`Length`가 그걸 못 잡는 게 맞고, 그건 별도 State로 계산해야 하는 사용자
몫. `Offset`은 Dispatch가 `setOffsetSource`로 등록받아 `recompute`
채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source<number>`.
`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로
해소됨 — 상세는 그 문서 참고.
## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가
사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를
@ -432,25 +568,34 @@ local observer = state:Observer(function()
Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리 Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리
Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push) Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push)
end) end)
observer:Subscribe() bindLifetime(inst, observer)
relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 들고 있어야 함 relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르려면 들고 있어야 함
``` ```
- children-array leaf 부착(`Frame { observer }`)이 **아니라** `:Subscribe()`/ **[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가
`:Unsubscribe()` 경로를 씀 — 이 Observer는 핸들러 내부 배관이라 사용자가 아니라 `bindLifetime`/`unbindLifetime`을 씀 — 원래 이 절이 "leaf가
보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer 아니니 `:Subscribe()`가 유일한 선택"이라고 적어뒀던 게 틀림.** `:Subscribe()`/
핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 `:Unsubscribe()`**`inst`와 아예 무관한 전역/독립** Observer(모듈
`:Subscribe()`가 유일한 선택). 최상위에 두는 디버그 print용 등)를 위한 전역 GC 방지 테이블 전용 —
- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐 — 위임 대상까지 "leaf가 아니면 `:Subscribe()`"가 아니라 "**`inst`에 안 묶이면**
수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 밑에 위임된 `:Subscribe()`, `inst`에 묶이면(leaf든 이런 핸들러 내부 배관이든)
걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 핸들러의 `retract` `bindLifetime`"이 실제 기준. 이 Observer는 처음부터 `inst`(그리고 그
정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위 "이벤트도 자식 프로퍼티 `k`)에 묶여있는 존재라 `bindLifetime`이 맞음 — 위 "이중
store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라,
이유(새 디스패치 메커니즘 없이 기존 계약만 구현). `:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임).
- **`retract`가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — 위임
대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기
밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이
핸들러의 `retract`는 정확히 자기 자신의 자원(Observer)만 정리하면
끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링
비용이 낮다"고 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존
계약만 구현).
- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 - **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가
이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md` 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`
`canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서
특별 취급됨). 특별 취급됨). `bindLifetime`도 이 `.Subscribed` 필드를 그대로
세팅/해제하므로(위 "이중 바인딩 금지" 절 참고) 이 게이팅은 그대로 유효.
- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 - Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은
코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회
적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로).
@ -1163,7 +1308,10 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue 안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue
로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인
엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table 엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table
기반 자동 추적이 적용 안 됨. 기반 자동 추적이 적용 안 됨. **[용어 정정, 2026-08-09 여섯 번째 세션]**
여기서 "weak-table 기반 자동 추적"이라 부른 것이 나중에 정식으로
`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘
두 개가 아니라 같은 것의 명명 전/후 표현.
**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 **해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새
설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클 설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클
@ -1194,9 +1342,13 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
- **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 - **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데
또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥
no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌.
- **`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀** — Instance가 - **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프)
파괴되기 전에 수동으로 조기 해제하고 싶을 때도 같은 메소드 하나로 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기
충분, 별도 API 안 만듦. 해제는 `unbindLifetime(inst, value)`가 담당, `:Unsubscribe()`
전역 강참조 레지스트리 경로 전용으로 남음.** `inst`를 모르는
`:Unsubscribe()``bindLifetime`이 어느 `inst`에 등록했는지 찾아낼
방법이 없어서(레지스트리가 `inst`별로 나뉘어 있음) 하나로 통합할 수
없음 — 위 "이중 바인딩 금지" 절의 정정 참고.
- **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상** - **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상**
— 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에
담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심
@ -1210,15 +1362,33 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게
체이닝 가능. 체이닝 가능.
### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정) ### 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정)
**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 **규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를
딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에
(위 weak table 경로) `:Subscribe()`로 수동 등록되거나(위 강참조 등록되거나(위 절), `bindLifetime(inst, value)`로 특정 `inst`에 종속되거나
레지스트리 경로), 둘 중 하나만. **둘 다 동시에 걸리는 건 UB로 확정** (아래 "`bindLifetime`도 같은 게이트를 공유" 절) — **이 둘 중 하나만**.
이미 leaf에 부착된 핸들을 다시 `:Subscribe()`하는 것도, 이미
`:Subscribe()`한 핸들을 children 배열에 놓아 leaf로도 부착시키는 것도 **[정정, 2026-08-09 여섯 번째 세션] "leaf 부착"은 세 번째 독립 경로가
둘 다 금지. 아니라 `bindLifetime`을 호출하는 것 그 자체다.** `Frame { observer }`처럼
children 배열에 Observer를 직접 놓으면, `Dispatch/Leaf.luau`가 이걸
매치해 내부적으로 `bindLifetime(inst, observer)`를 호출 — "children
배열에 놓여 leaf에 자동 부착"과 "`bindLifetime`으로 특정 `inst`
종속"은 **같은 동작**이라 서로 배타적일 수 없음(둘 다 하는 게 아니라
leaf 부착이 곧 `bindLifetime` 호출 방식 중 하나일 뿐). 그래서 실제
상호 배타는 "전역 소유(`:Subscribe()`)" vs "특정 `inst` 소유
(`bindLifetime`, 직접 호출이든 leaf 부착을 통한 호출이든)"라는
**2-way**로 정정 — 위 "Observer의 `:Subscribe()`/`:Unsubscribe()`" 절이
leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifetime`
정식 이름을 얻기 전(2026-08-06 후속 세션) 표현이라 지금은 같은 것을
가리킴 — 별도 메커니즘 두 개가 있던 게 아니었음.
**둘 이상 동시에 걸리는 건 UB로 확정** — 이미 한 경로로 바인딩된 핸들을
다른 경로로 또 바인딩하는 건 금지(leaf로 이미 부착된 걸 `:Subscribe()`
하는 것, 또는 그 반대). 같은 값을 `bindLifetime`으로 두 번(leaf 부착
한 번 + 직접 호출 한 번, 또는 leaf로 두 Instance에 부착) 등록하려는
것도 걸림 — 이건 "leaf vs bindLifetime 충돌"이 아니라 "같은 단일
메커니즘을 중복 호출"하는 것이라 자연히 같은 게이트가 잡아줌.
**UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 **UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상
0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다
@ -1236,9 +1406,10 @@ raw 필드(`self.Bound`)를 직접 보여주지 않고 같은 스타일의 탑
"코드 스타일 — 네이밍 케이싱" 절과 같은 기준): "코드 스타일 — 네이밍 케이싱" 절과 같은 기준):
```lua ```lua
-- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인 -- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침)
-- — 둘 다 진입 전 동일하게 확인
if not canBound(self) then if not canBound(self) then
error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음") error("Observer/Effect가 이미 다른 경로로 바인딩됨 — :Subscribe()와 bindLifetime(leaf 부착 포함)은 동시에 쓸 수 없음")
end end
-- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐) -- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐)
``` ```
@ -1248,26 +1419,80 @@ end
구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만, 구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만,
공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만 공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만
바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는 바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는
안 바뀜. 안 바뀜. **이 내부 플래그는 새 필드가 아니라 `canExecute`가 이미 보는
`.Subscribed` 필드 그 자체(2026-08-09 여섯 번째 세션 명시)** —
`:Subscribe()`뿐 아니라 `bindLifetime`도(Observer/Effect 값에 한해)
이 필드를 `true`로 세팅, `:Unsubscribe()`/`unbindLifetime` 둘 다
`false`로 되돌림 — 그래야 `bindLifetime`으로 등록된 Observer도
`canExecute`가 정상적으로 "살아있음"으로 인식함(필드를 둘로 나누면
`bindLifetime`으로만 등록된 Observer가 `canExecute`에서 항상
`false`로 오판됨).
- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 - 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만
답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게
대칭적으로 막힘. 대칭적으로 막힘.
- **`:Unsubscribe()`는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 - **`:Unsubscribe()``:Subscribe()` 경로의 해제만 담당, `bindLifetime`
뜻으로 통일** — 바인딩이 어느 경로로 세워졌든, `:Unsubscribe()` (leaf 부착 포함) 경로는 `unbindLifetime(inst, value)`로 해제** —
번으로 그 바인딩(leaf의 Destroying 연결이든 수동 강참조 등록이든)을 둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이
끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 자동(리프) 케이스에도 `unbindLifetime`도 대칭적으로 부르는 책임을 짐 — `inst`를 모르는
동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 별개로, "단일 `:Unsubscribe()`가 대신 처리할 수 없는 정보라서). leaf 부착으로
바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. 세워진 바인딩의 실제 해제도(예: Instance 파괴 전 조기 해제하고 싶을
- **Effect도 동일 규칙 적용** — 내부적으로 Observer를 조합하는 경우든 때) 결국 `unbindLifetime`이 담당 — 위 "`:Unsubscribe()`는 자동(리프)
`state` 없는 경우든 같은 `canBound` 게이트를 그대로 재사용 케이스에도 동일하게 씀" 절의 서술은 leaf 부착이 별도 메커니즘이라고
(`base/effect-plan.md`). 이전에 그 문서에 적어뒀던 "leaf 부착과 전제했던 것이라 **이 정정으로 대체**(`:Unsubscribe()`가 아니라
`:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 규칙으로 `unbindLifetime`이 leaf 해제의 실제 통로).
대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 하는 - **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로
조합이었음. 내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은
`canBound` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect
자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이
이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf
부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이
규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야
하는 조합이었음.
- **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children - **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children
배열에 놓았다면 그걸 다시 `:Subscribe()`하지 말 것, 반대도 마찬가지 — 배열에 놓았다면(=`bindLifetime`으로 등록된 것) 그걸 다시
두 경로를 동시에 쓰고 싶으면 각각 독립된 새 `Effect(...)`/ `:Subscribe()`하거나 다른 Instance에 또 leaf로 놓지 말 것, 반대도
`state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. 마찬가지 — 여러 경로를 동시에 쓰고 싶으면 각각 독립된 새
`Effect(...)`/`state:Observer(...)` 호출로 따로 만들 것"을 명시할 것.
### `bindLifetime`이 이 게이트의 두 번째(이자 leaf 부착이 실제로 쓰는) 진입점이다 (2026-08-09 여섯 번째 세션)
`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때
쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은
`canBound` 게이트를 확인** — Observer/Effect 값을 `bindLifetime`할 때도
진입 전 `canBound(value)`를 확인하고, 통과하면 바인딩됨으로 표시.
**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** —
`Dispatch/Leaf.luau``(i:number, v=Observer/Effect)`를 매치하면
그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용"
바인딩 로직이 따로 있는 게 아님. 그래서 **실제 상호 배타는 `:Subscribe()`
(전역 강참조 레지스트리)와 `bindLifetime`(inst별 gchold, 직접 호출이든
leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만들 이유가
없음, 기존 게이트에 진입점 하나(`bindLifetime`, leaf 부착이 그 특수
사례)만 추가.
```lua
function bindLifetime(inst, value)
local isOE = isObserver(value) or isEffect(value)
if isOE and not canBound(value) then
error("Observer/Effect가 이미 다른 경로로 바인딩됨")
end
... -- gchold 등록(base/lifecycle-pattern.md)
if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용
end
function unbindLifetime(inst, value)
... -- gchold 해제
if isObserver(value) or isEffect(value) then value.Subscribed = false end
end
```
- **비-Observer/Effect 값(예: Tween 내부에 쓰는 평범한 클로저)은 이 게이트
자체가 안 적용됨** — `canBound``.Subscribed`류 필드가 있는 Observer/
Effect 전용 predicate라, 그 외 값은 `bindLifetime`이 그냥 통과시킴(leaf/
`:Subscribe()` 경로 자체가 성립 안 하는 값들이라 충돌 대상이 없음).
- Observer/Effect가 `bindLifetime`으로 바인딩된 뒤엔 `canBound`
`false`를 반환하므로, 그 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면
기존 두 진입점의 기존 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가
없이 이미 성립.
**quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는 **quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는
`archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만 `archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만

View file

@ -108,10 +108,13 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세
규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` 규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound`
플래그, 2026-08-09 세션에서 이름 확정)은 플래그, 2026-08-09 세션에서 이름 확정)은
`base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. leaf 부착 `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정,
**후** `:Unsubscribe()`로 조기 해제하는 것(위 "Observer의 2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`
`:Unsubscribe()`는 자동 케이스에도 동일하게 씀" 패턴)은 여전히 정상 — 아니라 `unbindLifetime(inst, value)`** — leaf 부착 자체가 내부적으로
금지되는 건 leaf 부착과 `:Subscribe()`**같이** 쓰는 것뿐. `bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime`
전용(`:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함) — 금지되는 건
여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함,
inst-scoped 경로)을 **같이** 쓰는 것뿐.
## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) ## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체)

View file

@ -130,19 +130,35 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면
실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). 실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결).
### `bindLifetime`/`canExecute` — 확정(2026-08-08 세션) ### `bindLifetime`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션,
`unbindLifetime`은 2026-08-09 세션 추가)
**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ **탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/
`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ `Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/
`canExecute``isState`/`isObserver`처럼 핸들러 작성자가 직접 호출하는 `canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 핸들러 작성자가
**1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 감싸면 안 됨 — 직접 호출하는 **1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로
`LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 평평한 함수: 감싸면 안 됨 — `LifetimeHandle.luau` 파일 안에 있어도 되지만 export는
평평한 함수:
```lua ```lua
bindLifetime(inst: any, value: any): () bindLifetime(inst: any, value: any): ()
unbindLifetime(inst: any, value: any): ()
canExecute(inst: any, value: any): boolean canExecute(inst: any, value: any): boolean
``` ```
**`unbindLifetime` 추가 이유(2026-08-09 세션, `bind-system-plan.md`
"Length/Offset" 논의에서 파생)**: `Dispatch.setLength`(같은 위치에 새
`State<number>`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함,
`State<Slot>` 교체가 대표 사례)처럼 **`inst` 전체 생명주기보다 먼저,
특정 값 하나만 콜백/구독을 끊어야 하는 경우**가 실제로 생김 —
`bindLifetime`만 있으면 그 호출부가 gchold의 내부 저장 구조(배열이든
`value`를 키로 쓰는 테이블이든)를 직접 알아야만 특정 항목을 지울 수
있어서 캡슐화가 깨짐. `unbindLifetime(inst, value)`을 짝으로 추가하면
호출부는 내부 구조를 몰라도 됨 — 구현이 쉬운 이유도 여기 있음(아래
스케치처럼 gchold를 `value`를 키로 쓰는 테이블로 두면 `gchold[value] =
nil` 한 줄). 안 걸려있던 값에 불러도 안전한 no-op(`:Unsubscribe()`류
기존 관례와 동일).
base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가 base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가
`BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute` `BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute`
관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md` 관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md`
@ -156,11 +172,18 @@ local GCCONN = "__gcconn"
local GCHOLD = "__gchold" local GCHOLD = "__gchold"
function bindLifetime(inst, value) function bindLifetime(inst, value)
local isOE = isObserver(value) or isEffect(value)
-- leaf 부착도 내부적으로 이 함수를 호출하므로, :Subscribe()와 상호
-- 배타적인 "이중 바인딩 금지"(base/bind-system-plan.md)를 여기서 확인
if isOE and not canBound(value) then
error("Observer/Effect가 이미 다른 경로로 바인딩됨")
end
local gcconn = relate:GetStrong(inst, GCCONN) local gcconn = relate:GetStrong(inst, GCCONN)
if not gcconn then if not gcconn then
-- ClassName은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음 -- ClassName은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음
-- (rbvm 패턴 그대로) — 콜백 클로저가 gchold를 업밸류로 캡쳐해 살려둠 -- (rbvm 패턴 그대로) — 콜백 클로저가 gchold를 업밸류로 캡쳐해 살려둠
local gchold = {} local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로
relate:SetStrong(inst, GCHOLD, gchold) relate:SetStrong(inst, GCHOLD, gchold)
gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존 local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존
@ -168,14 +191,23 @@ function bindLifetime(inst, value)
relate:SetStrong(inst, GCCONN, gcconn) relate:SetStrong(inst, GCCONN, gcconn)
end end
local gchold = relate:GetStrong(inst, GCHOLD) local gchold = relate:GetStrong(inst, GCHOLD)
table.insert(gchold, value) -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC gchold[value] = true -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC
if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용
end
function unbindLifetime(inst, value)
local gchold = relate:GetStrong(inst, GCHOLD)
if gchold then
gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제
end
if isObserver(value) or isEffect(value) then value.Subscribed = false end
end end
function canExecute(inst, value) function canExecute(inst, value)
-- Observer/Effect는 자기 바인딩 경로(leaf 부착 또는 :Subscribe())의 -- Observer/Effect는 자기 바인딩 경로(bindLifetime=leaf 부착 포함,
-- 생존 여부를 스스로 알고 있음 — inst가 살아있어도 이 값이 먼저 죽어 -- 또는 :Subscribe())의 생존 여부를 스스로 알고 있음 — inst가 살아있어도
-- 있을 수 있으므로(예: retract가 :Unsubscribe()만 하고 inst는 안 죽음) -- 이 값이 먼저 죽어 있을 수 있으므로(예: retract가 unbindLifetime만
-- 반드시 먼저 확인. -- 하고 inst는 안 죽음) 반드시 먼저 확인.
if (isObserver(value) or isEffect(value)) and not value.Subscribed then if (isObserver(value) or isEffect(value)) and not value.Subscribed then
return false return false
end end

View file

@ -139,20 +139,20 @@ ref처럼 바인드됨 — **사용자 확정**("A. 맞음. 리프노드에선
Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래 Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래
참고. 참고.
### 여러 Slot이 섞일 때 순서 보장 — 열린 질문 (2026-08-04 신규) ### 여러 Slot이 섞일 때 순서 보장 — 해소됨 (2026-08-09 여섯 번째 세션)
`Frame { Slot1, 일반자식, Slot2 }`처럼 Slot과 Slot 사이에 다른 요소가 끼거나 `Frame { Slot1, 일반자식, Slot2 }`처럼 Slot과 Slot 사이에 다른 요소가 끼거나
Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가 Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가
항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지는 아직 설계 안 됨 — 항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지가 2026-08-04부터 열려
**사용자 확정**("순서도 따름, 만약 웹이라면 순서에 맞춰서 마운트 되어야 하고, 있었던 질문 — **메커니즘 확정으로 해소됨**: `Dispatch.setLength`/
아래쪽 slot은 위쪽 slot의 요소보다 무조건 아래 있어야함... 이게 가능하냐는 `Dispatch.setOffsetSource` + 형제별 개수 누적합(`offset`)을 리액티브
생각해봐야할 이야기임"). Roblox 자체는 z-순서를 주로 `LayoutOrder`/`ZIndex`로 프로퍼티(Roblox `LayoutOrder`)에 바인딩하는 방식 — 상세는 `base/
푸는 편이라 당장 급하게 막히는 지점은 아니지만, `architecture.md`가 명시한 bind-system-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 순서
"quad-base는 다른 렌더 백엔드(GTK, 웹 DOM 등)에서도 재사용 가능해야 한다"는 보장" 절 참고. **DOM류 물리 순서 백엔드에도 같은 base 메커니즘이 그대로
전제와 직결되는 문제 — DOM류 백엔드는 형제 순서 자체가 렌더 순서라 이 보장이 재사용됨**(offset이 바뀌어도 이미 마운트된 원소를 물리적으로 옮길 필요
으면 못 씀. **후순위 열린 질문으로 유지**, Slot 코어 로직 구현 시점에 음 — `insertBefore`가 뒤 형제를 자연히 밀어주므로, backend Handler의
다시 볼 것 — M0/M1 단계를 막지는 않음(Roblox 단일 백엔드로는 당장 문제 "offset 변경 시 할 일"만 no-op으로 달라짐) — `architecture.md`의 "다른
없음). 렌더 백엔드에서도 재사용 가능해야 한다"는 전제와도 부딪히지 않음.
## Slot과 Store 바인드의 관계 (`retract` 순서) ## Slot과 Store 바인드의 관계 (`retract` 순서)
@ -546,6 +546,26 @@ end
- **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론, - **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론,
`isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) — `isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) —
위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고. 위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고.
- **여러 Slot이 형제로 섞일 때 순서 보장**은 아직 열려있음(위 "여러 Slot이 - **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때
섞일 때 순서 보장" 절 참고) — Roblox 단일 백엔드로는 급하지 않음, Slot 순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은
코어 로직 구현 시점에 재검토. `base/bind-system-plan.md`의 "Length/Offset" 절이 최신 소스.
## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션)
Slot은 CRUD/`:List` 여부와 무관하게 `.Length: State<number>`를 항상
노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 `:List`
reconcile이 넣든 동일). 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은
UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로
그 값 — 별도 두 State가 아니라 하나. `:List`의 filter 탈락이 실제
`Remove`(Visible 토글 아님)로 확정돼 있어서 `Length`는 자동으로 "실제
마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 `Length`가 그걸 못 잡는
게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫.
## 백로그 — `Slot():Single(state, updateFn?)` (2026-08-09 여섯 번째 세션, 미착수)
`:List`의 key-map(`mounted`/`userdata`/`keyIndex`) 없이 "0개 아니면 1개"만
다루는 더 가벼운 편의 메소드 제안(예: `state<Frame?>`를 조건부로 마운트하는
관용구를 더 명시적으로 표현) — `.Length`는 그냥 0/1이고 나머지(offset 소비,
LayoutOrder 바인딩)는 일반 Slot과 완전히 같은 프로토콜. 아직 상세 설계
안 함, `.claude/question.md`에 백로그로만 반영.

View file

@ -34,6 +34,9 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
- Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 - Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이
기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단(`research/ 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단(`research/
additional-primitives-plan.md` "빈 자리 아닌 것" 절). additional-primitives-plan.md` "빈 자리 아닌 것" 절).
- **[백로그, 2026-08-09 여섯 번째 세션 추가, 미착수]** `Slot():Single(state,
updateFn?)` — `:List`의 key-map 없이 "0개 아니면 1개"만 다루는 가벼운
편의 메소드. `base/slot-plan.md` "백로그 — `Slot():Single(...)`" 절.
### 1. 용어 정리 (사용자 요청, 진행 중) ### 1. 용어 정리 (사용자 요청, 진행 중)
@ -190,11 +193,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
- **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요**
`reference/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 `reference/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션
가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록. 가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록.
- **여러 Slot이 형제로 섞일 때 순서 보장**`base/slot-plan.md`의 "여러 - **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때
Slot이 섞일 때 순서 보장" 절 참고. Roblox 단일 백엔드로는 급하지 않음 순서 보장 — `Dispatch.setLength`/`Dispatch.setOffsetSource` + 형제별
(LayoutOrder/ZIndex로 대부분 해결), 다른 백엔드(웹 DOM 등) 재사용성과 개수 누적합을 `LayoutOrder`에 리액티브 바인딩하는 메커니즘으로 확정,
직결되는 문제라 Slot 코어 로직 구현 시점에 재검토. **같은 구현 시점에 DOM류 물리 순서 백엔드에도 같은 base 로직이 재사용됨(backend Handler의
같이 확인할 것(2026-08-06 추가)**: Slot이 quad 밖(v1 compat 등)에서 "offset 변경 시 할 일"만 no-op으로 갈림). 상세는 `base/
bind-system-plan.md` "Length/Offset" 절, `base/slot-plan.md` "여러
Slot이 섞일 때 순서 보장" 절. **같은 구현 시점에 같이 확인할 것
(2026-08-06 추가, 아직 안 풀림)**: Slot이 quad 밖(v1 compat 등)에서
만들어진 임의 Instance를 동적 배열 원소로 받을 수 있는지, retract 시 만들어진 임의 Instance를 동적 배열 원소로 받을 수 있는지, retract 시
foreign Instance를 어떻게 다루는지 — `research/v1-compat-plan.md` 7-3 foreign Instance를 어떻게 다루는지 — `research/v1-compat-plan.md` 7-3
참고. 참고.

110
CLAUDE.md
View file

@ -2149,3 +2149,113 @@ Source/Store를 새로 안 만들려면 이전 상태를 어딘가 저장해야
`research/documentation-content-map.md` 반영 완료. `research/documentation-content-map.md` 반영 완료.
**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). **다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터).
## 2026-08-09 여섯 번째 세션 — 여러 Slot이 형제로 섞일 때 순서 보장 완전
해소(Length/Offset), `unbindLifetime` 신설
**출발점**: 사용자가 미래의 `quad-web`을 가정하며 `{ Slot, Element, Slot }`처럼
Slot이 여럿 형제로 섞일 때 최종 순서를 어떻게 보장하는지 물음 —
2026-08-04부터 "Roblox 단일 백엔드로는 급하지 않음"으로 후순위 열려있던
질문(`slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 절)을 실제로 라이브
설계해서 완전히 풀어낸 긴 단일 스레드. 시행착오를 거쳐 최종 수렴한 결론만
정리(중간 대안들 — "구간 예약"/`:With`+`:Compute` 체인 — 은 채택 안 됨,
사용자가 제시한 "정확한 누적합 + 플랫 재계산 루프"가 최종안):
- **핵심 전환**: "각 원소가 절대 위치를 계산해서 전파"가 아니라 "각
구조적 위치가 자기 앞 형제들의 개수 누적합(`offset`)만 알면 됨" —
Roblox `LayoutOrder`가 이미 `Instance.Parent` 물리 순서와 분리된
정수 프로퍼티라는 사실이 이 전환을 공짜로 성립시킴.
- **`Dispatch.setLength(inst,i,len:number|State<number>)`/
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|None)`** —
둘 다 Handler→Dispatch 등록(push) 방향, array part의 **모든** number
인덱스에 대해 반드시 호출(생략 UB — Handler 구현체 작성자만의 계약,
일반 사용자 영향 없음). `recompute`는 매번 `1..N` 전체를 도는 단순
루프(N은 저작 시점에 고정된 배열 리터럴 길이라 무시 가능)로, 각
`offset:Set()` 호출 앞에서만 `Get() ~= sum` 가드를 걸어 실제로 안
바뀐 위치의 캐스케이드(다운스트림 `LayoutOrder` 재적용)를 막음 —
전체 순회 비용과 `Set` 캐스케이드 비용을 분리해서 후자만 최적화.
- **각 원소의 `LayoutOrder``localIndex+offset`의 State를 기존
store-bind 프로퍼티 바인딩에 그냥 얹는 것** — 이게 이 설계의 가장
큰 단순화 지점: "offset 변경 시 이미 마운트된 원소를 다시 써야 한다"는
요구가 새 push/observer 메커니즘 없이 **이미 있는** store-bind
재실행 모델(`state:Observer(fn):Subscribe()`) 재사용만으로 공짜로
풀림.
- **`setLength`의 내부 Observer는 leaf-lifetime 경로(`bindLifetime`)를
씀, `:Subscribe()` 아님** — 이 Observer는 특정 leaf가 아니라 `inst`
자신에 종속된 내부 배관이라, `inst` Destroy 시 자동으로 안 죽는
`:Subscribe()` 경로는 안 맞음. `State<Slot>` 교체처럼 `inst` 전체가
죽기 전에 특정 위치 하나만 조기 재등록해야 하는 경우를 위해
**`unbindLifetime(inst,value)``bindLifetime`/`canExecute`의
세 번째 짝으로 신설** — `Dispatch.setLength`가 gchold 내부 저장
구조(배열/키드 테이블)를 몰라도 이전 등록을 블랙박스로 해제할 수
있게 캡슐화. quad-roblox 구현 스케치도 gchold를 배열 대신 `value`
키로 쓰는 테이블로 바꿔 `unbindLifetime`을 O(1)로(`gchold[value] =
nil`) — base 결정은 아니고 참고용 스케치.
- **동기 순서 요구사항**: Slot의 `rawAdd``Length:Set(newCount)`
(다운스트림 offset/LayoutOrder 캐스케이드가 여기서 동기적으로 끝남)
다음에 `element.Parent = target`을 호출 — Source:Set()이 옵저버
체인을 동기적으로 끝까지 도는 기존 모델 덕에 별도 배리어 없이 순서만
지키면 자동 성립. 안 지키면 Roblox의 실시간 `UIListLayout` reflow가
한 프레임 잘못된 순서를 노출할 위험.
- **`Slot.Length: State<number>`가 CRUD/`:List` 여부와 무관하게 항상
노출되는 프리미티브 필드로 확정** — 사용자가 직접 "n개 검색됨" UI에도
쓸 수 있다고 지적, `setLength`가 내부적으로 읽는 값과 완전히 동일(두
용도를 겸함, 별도 State 아님). `:List`의 filter=진짜 Remove 확정
덕에 "Visible 토글은 안 잡힘"이 자연히 성립(새 캐비엇 아님).
- **웹 백엔드(quad-web) 일반화 — base 로직 100% 재사용, backend
Handler의 "offset 변경 시 할 일"만 달라짐**: DOM `insertBefore`
물리적 삽입 시 뒤 형제를 자동으로 밀어주므로, offset이 바뀌어도
이미 마운트된 노드를 실제로 옮길 필요가 없음 — quad-web Handler는
offset 변경 관측 시 no-op, 숫자는 그 위치가 **다음** insert/remove
때 쓸 물리 인덱스로만 부기됨. 처음 검토했던 "구간 예약"(고정 gap)이나
"앵커 기반 상대 삽입" 안보다 이 방식이 dense global rank라 두 종류
백엔드(순서-분리 프로퍼티형/물리-순서형) 모두에 더 직접적으로 맞음.
- **백로그로만 남김**: `Slot():Single(state, updateFn?)``:List`
key-map 없이 "0 또는 1"만 다루는 가벼운 편의 메소드, 상세 설계 미착수.
**같은 세션 후속 — `bindLifetime`/`unbindLifetime`이 실제로 뭘 하는지,
`canBound`(이중 바인딩 금지)와의 관계를 여러 차례 시행착오 끝에 정확히
확정.** `Dispatch.setLength`가 이전 Observer 등록을 정리할 때 뭘 불러야
하는지를 두고 제가 세 번 틀렸다가 사용자가 매번 정정 — 경위와 최종
결론을 구분해서 기록:
1. **1차 시도(틀림)**: `unbindLifetime``canExecute`를 즉시 `false`
만들어준다고 서술 — 틀림. `gchold`(순수 GC 방지용 강참조 테이블)는
`canExecute`가 보는 값(Observer/Effect의 `.Subscribed`, 또는 `inst`
공유 `gcconn.Connected`) 어디에도 안 들어감, 완전히 무관한 테이블.
2. **2차 시도(틀림)**: 그래서 "`unbindLifetime`은 필요 없고 `:Unsubscribe()`
만 쓰면 된다"로 후퇴 — 이것도 틀림. 사용자 정정: `:Subscribe()`/
`:Unsubscribe()`**`inst`와 아예 무관한 전역/독립** Observer(모듈
최상위 디버그 print 등, leaf도 없고 특정 Instance에도 안 묶인 경우)를
GC로부터 지키기 위한 **전역** 강참조 테이블(`SubscribedObservers[observer]
= true/nil`)일 뿐 — `Dispatch.setLength`의 Observer처럼 처음부터
`inst` 하나에 종속된 내부 배관에는 원래부터 안 맞는 도구. "`inst`
연관은 전부 `bindLifetime`/`unbindLifetime`으로"가 맞는 원칙.
3. **최종 확정**: 진짜 독립된 라이프사이클 경로는 **`:Subscribe()`(전역)
`bindLifetime`(inst-scoped) 둘뿐** — "children 배열 leaf 부착"은
세 번째 경로가 아니라 **`bindLifetime` 호출 그 자체**(`Dispatch/
Leaf.luau`가 Observer/Effect leaf를 매치하면 그 자리에서
`bindLifetime(inst, v)`를 호출), 이걸 제가 처음에 "leaf 부착/
`:Subscribe()`/`bindLifetime` 셋 다 상호 배타"로 잘못 일반화했다가
사용자가 "leaf 부착 자체가 bindLifetime을 호출하는 거라 동일 동작,
상호배타는 아니다"로 정정. `canBound`의 내부 플래그도 새 필드가
아니라 **`canExecute`가 이미 보는 `.Subscribed` 그 자체** —
`bindLifetime`/`unbindLifetime`도(Observer/Effect 값에 한해) 이
필드를 세팅/해제해야 `bindLifetime`으로 등록된 Observer가
`canExecute`에서 정상적으로 "살아있음"으로 인식됨. Effect는 내부적으로
Observer를 조합하므로 이 확장을 몰라도 자동으로 커버(사용자 확인).
4. **부수 정리**: 이미 확정돼 있던 StoreBind의 자기 재실행 Observer
예제(`observer:Subscribe()`)도 같은 이유로 틀렸던 것이었음 확인 —
`bindLifetime`/`unbindLifetime`으로 교체. "`:Unsubscribe()`는 자동
(리프) 케이스에도 동일하게 씀"이라던 기존 서술도 같은 이유로 정정
(리프/`bindLifetime` 경로의 조기 해제는 `unbindLifetime` 전용,
`:Unsubscribe()``inst`를 몰라 대신 처리 못 함).
전부 `base/bind-system-plan.md`(신규 "Length/Offset" 절, "이중 바인딩
금지" 절 정정 — 2-way로 재확정, StoreBind 예제 교체)/`base/slot-plan.md`
(열린 질문 해소, `Slot.Length` 절, `:Single` 백로그 절)/`base/
lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed`
연동 반영)/`ROADMAP.md`(M2/M3/M6)/`.claude/question.md` 반영 완료.
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수
설계 확정이라 M0 착수 우선순위 자체는 그대로.

View file

@ -93,18 +93,35 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를 재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를
대체(2026-08-08 세션 신설). 대체(2026-08-08 세션 신설).
- [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/ - [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/
`canExecute(inst,value)` 탑레벨 함수 타입 계약, 실 구현 없음 — `unbindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수
quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만
`Connected` 확인)/M6(Slot의 `canExecute`)이 이미 이 인터페이스를 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이
전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(`pre-implementation-audit.md` 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼
우선순위1-9, `question.md` 2번 — 2026-08-07 네 번째 세션에 반영). 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md`
2번 — 2026-08-07 네 번째 세션에 반영).
**`canExecute``(inst, value) -> boolean`으로 재확정(2026-08-08 **`canExecute``(inst, value) -> boolean`으로 재확정(2026-08-08
세션, `(handle)` 단일 인자 서술을 대체)** — Observer/Effect는 자기 세션, `(handle)` 단일 인자 서술을 대체)** — Observer/Effect는 자기
`Subscribed` 상태를 먼저 확인, 그 다음 `inst`의 공유 gcconn(`Relate`로 `Subscribed` 상태를 먼저 확인, 그 다음 `inst`의 공유 gcconn(`Relate`로
저장)의 `.Connected`를 봄. `bindLifetime`/`canExecute` 둘 다 네임스페이스 저장)의 `.Connected`를 봄. **`unbindLifetime(inst,value)` 추가
(2026-08-09 여섯 번째 세션)** — `inst` 전체 죽기 전에 특정 값 하나만
조기 해제(`Dispatch.setLength`가 State 재등록 시 이전 Observer를
정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화.
`bindLifetime`/`unbindLifetime`/`canExecute` 셋 다 네임스페이스
없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분, 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분,
`isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/ `isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/
lifecycle-pattern.md`의 "`bindLifetime`/`canExecute` — 확정" 절 참고 lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime`
— 확정" 절 참고. **Observer/Effect 값에는 `bindLifetime`/
`unbindLifetime`도 M3의 `canBound` 게이트를 확인/세팅** — children
배열 leaf 부착이 실제로는 `bindLifetime` 호출이라서(M3 체크박스
참고, 구현 순서상 M2가 M3의 `canBound`를 참조하게 됨에 유의)
- [ ] `Dispatch.setLength(inst,i,len:number|State<number>)`/
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|None)`
array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브
바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
(2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset"
절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소)
- [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서 - [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서
걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러 걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러
교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약" 교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약"
@ -143,10 +160,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션)
- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로 leaf - [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로
부착과 `:Subscribe()`가 동시에 걸리면 즉시 `error`(`base/bind-system-plan.md` `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도
"이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 이름은 내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/
2026-08-09 세션에 `canBound`로 확정) bind-system-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째
세션 신설, 이름은 2026-08-09 세션에 `canBound`로 확정, 같은 날
여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜
독립 경로는 둘뿐). `canBound`의 내부 플래그는 `canExecute`가 보는
`.Subscribed`와 같은 필드 — `bindLifetime`/`unbindLifetime`도
(Observer/Effect 값에 한해) 이 필드를 세팅/해제
- [ ] mock 대상 테스트 - [ ] mock 대상 테스트
## M4 — 첫 end-to-end 반응형 업데이트 ## M4 — 첫 end-to-end 반응형 업데이트
@ -168,8 +190,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M6 — Slot ## M6 — Slot
- [ ] "여러 Slot이 형제로 섞일 때 순서 보장" 열린 질문 확인(`slot-plan.md`) — - [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째
Roblox 단일 백엔드로는 급하지 않으면 스킵하고 진행 가능 세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/
bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
- [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론 - [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론
확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지 확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지
전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시