qa: 구현 전 QA 3라운드 — attachSlot/bk.N 트레이싱으로 RC-3/RC-4 발견·해결

RC-1 Blocker 게이팅이 실제로 attachSlot에 반영된 걸 손으로 트레이싱하다
:List 최초 population이 이중 처리되는 결함(RC-3/RC-4)과 recompute가
의존하는 bk.N의 수명주기가 문서에 없던 갭을 발견. 필자의 최초 분석
오류(bk.N을 그때그때 실제 개수로 두면 크래시가 되돌아온다는 판단)를
사용자가 직접 정정 — Blocker 게이팅은 bk.N이 아니라 blocker:IsOn()만
보므로 무관함이 밝혀졌고, RC-3/RC-4도 slot._mounted를 activateList
호출 뒤로 미루는 사용자 설계로 해결됨. ROADMAP M2가 M3의 Blocker.luau에
의존하게 된 마일스톤 순서 불일치도 발견해 각주로 반영.

quad-doc-auditor 감사 루프 4라운드(1~3라운드 총 9건 발견·수정, 4라운드
무발견으로 종료) 거쳐 doc-check.py ERROR 0 확인 후 커밋.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-19 00:07:30 +09:00
parent b419d8c5e5
commit 96c8c2eaa1
No known key found for this signature in database
10 changed files with 705 additions and 30 deletions

File diff suppressed because one or more lines are too long

View file

@ -80,6 +80,54 @@ Blocker 재사용 설계를 직접 제시해 해결됨.
절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은 절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절. `qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절.
## [해소됨, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 + `RC-3`/`RC-4`(`attachSlot`의 `:List` 초기 population 중복 처리)
`recompute`가 순회 상한으로 쓰는 `bk.N`이 Slot을 ownerKey로 재사용할 때
무엇이고 언제 갱신되는지 문서에 없던 갭(`qa-request/
pre-implementation-qa-round3.md`가 원본) — 트레이싱 중 `attachSlot`
`:List`의 최초 population을 이중 처리하는 결함(`RC-3`/`RC-4`)도 같이
발견됐고, 셋 다 같은 세션에 사용자가 직접 해법을 제시해 해결됨.
- **`bk.N` = 그때그때 실제 개수**, `inst`/Slot 두 owner 타입 동일 규칙 —
`Dispatch.setLength`(항상 뒤에 불림, `setOffsetSource`는 안 건드림)가
이전에 없던 더 큰 position을
등록할 때마다 늘고, `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`)이
위치를 구조적으로 지울 때 줄어듦. **최초 분석 오류를 사용자가 직접
정정**: 필자는 "그때그때 실제 개수"면 배치 등록 중 `RC-1`과 같은
크래시가 되돌아온다고 판단했으나, Blocker 게이팅은 `bk.N`이 아니라
`blocker:IsOn()`만 확인하므로 배치 중엔 `recompute` 자체가 안 돌아
`bk.N`이 무엇이든 무관함 — `RC-1`의 원래 크래시는 "`bk.N`이 배치 전에
이미 최종 크기로 고정"이라는, 이제는 사라진 전제의 부산물이었을 뿐.
Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가 아니라 배치 비용
(O(N²)→O(N)).
- **`RC-3`/`RC-4`**: `attachSlot``slot._mounted = true`를 맨 위에서
세팅해뒀던 탓에, `:List`의 최초 reconcile(`activateList`, 아직
flush 루프의 Blocker가 켜지기 전)이 부르는 `rawAdd`가 "이미 마운트됨"
경로를 타 항목마다 무게이팅 `recompute`가 돌고(`RC-3`), nested Slot
요소는 그 자리에서 이미 `attachSlot`된 뒤 뒤이은 flush 루프가 같은
요소를 또 `attachSlot`해 이중 실행됐다(`RC-4`). **해법(사용자 제시)**:
`slot._mounted = true``activateList` 호출 **뒤**로 옮기면 끝 —
`activateList` 도중엔 `rawAdd`가 "아직 마운트 전" 경로(그냥
`_elements`에만 push)를 타고, flush 루프가 `:List`/수동 CRUD 구분 없이
모든 요소를 처음이자 한 번만 물리 마운트한다. `rawAdd`의 "이미
마운트됨" 즉시-`attachSlot` 분기 자체는 그대로 남음 — 최초 flush
이후의 런타임 갱신(예: `:List``data`가 나중에 바뀌어 nested Slot이
새로 추가되는 경우)엔 여전히 필요한 경로이기 때문.
- **부수 발견**: `spliceArraysDown`이 밀어야 할 배열 목록에
`bk.observers`가 빠져 있었음(`_elements`/`lengthList`/`sourceList`
셋만 서술돼 있었음) — 같이 반영.
- **`ROADMAP.md` 마일스톤 정합성**: `RC-1`의 Blocker 게이팅으로 M2
(`Dispatch.setLength`/`setOffsetSource`)가 M3 체크박스에 있는
`Blocker.luau`에 구조적으로 의존하게 됐는데 로드맵 어디에도 이 순서
의존이 명시가 안 돼 있던 것도 발견 — 가장 보수적인 조치(마일스톤
재편 없이 M2 체크박스에 각주만 추가)로 우선 반영, 재편 여부는 열려
있음.
- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "저장 위치"/"배치
등록을 안전하게 만드는 Blocker 게이팅" 절, `base/slot-plan.md`
"재귀 메커니즘"/"파괴" 절, `base/blocker-plan.md`의 "두 번째 용례"
절이 소스. 논의 원문(최초 분석·사용자 정정·확인 질문과 답변 전문)은
`qa-request/pre-implementation-qa-round3.md`.
--- ---
# 확인/결정 필요 목록 # 확인/결정 필요 목록

View file

@ -97,7 +97,11 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
"Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩 "Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩
순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를 순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를
고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다 고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다
— 콜백 안에서 `blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는 **[정정, 2026-08-18 구현 전 QA 3라운드]** 그 크래시 자체는 이후
`bk.N`(순회 상한)의 정의를 고치며 사라졌지만(`base/dispatch-core-plan.md`
"저장 위치" 절), 이 용례는 그대로 유효하다 — 이유가 크래시 방지에서
배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서
`blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는
방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end` 방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end`
형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지 형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지
않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 — 않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 —

View file

@ -1244,10 +1244,49 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는
이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. 이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨.
**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 **저장 위치**: `lengthList`/`sourceList`/`observers`(부모 `inst` 하나에
`inst`의 array part 크기 `N``bk.N`으로 같이 저장, `Dispatch.drive` 귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장)
최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)` `Relate(parentInst)`에 lazy 생성.
lazy 생성.
**[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner
타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔
`bk.N`을 "`Dispatch.drive`가 최초 배열 파트 순회 시점에 이미 아는, 저작
시점에 고정된 값"으로만 서술했는데, `base/slot-plan.md`의 "재귀
메커니즘" 절이 같은 `recompute`/`getBookkeeping`을 **Slot 자신**을
ownerKey로 재사용하면서 이 전제(N이 고정)가 안 맞는 케이스가 생겼다 —
Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이유). **사용자
확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될
때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에
동일하게 적용"* — 즉:
- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`
등록할 때마다 `bk.N``i`로 늘어난다(`Dispatch.drive`의 배열 파트
순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이
하나의 규칙) — **`Dispatch.setOffsetSource``bk.N`을 건드리지
않는다**, 호출 순서가 항상 `setOffsetSource(i)``setLength(i)`라서
(아래 "`setLength` 구현" 절) `bk.N``setLength`에서만 올려야
`lengthList[i]`가 아직 안 채워진 채로 `bk.N`만 먼저 커지는 창이 안
생긴다.
- `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`가 부름, `base/
slot-plan.md` "파괴" 절)이 position 하나를 구조적으로 제거할 때마다
`bk.N`이 그만큼 줄어든다.
- `Dispatch.drive``inst`에서는 이 규칙이 사실상 안 보인다 — 최상위
배열 리터럴은 구조적으로 늘거나 줄지 않으므로(재-dispatch는 전체
교체) `bk.N`이 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도
케이스가 아니라 같은 규칙의 특수한 안정 상태다.
**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치
등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가
`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로,
배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도
안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 이미
최종 크기로 고정돼 있었던 것**의 부산물이었을 뿐 — 지금은 그 전제 자체가
없다. 그런데도 Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가
아니라 **비용**이다(등록마다 `recompute`가 한 번씩 도는 O(N²) 대신
배치 끝에 O(1)번만) — `RC-1` 해결 논의에서 사용자가 직접 지적한 "이러면
첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로. 상세 트레이싱은
`qa-request/pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기가
명세에 없음" 절.
**`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트 **`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트
원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) 원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙)
@ -1349,8 +1388,11 @@ end
문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치 문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치
vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것. vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것.
전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 전체 순회의 O(N) 비용은 무시 가능(`Dispatch.drive`의 최상위 `inst`
길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 기준으로는 `N`이 저작 시점에 고정된 배열 리터럴 길이, 보통 작음 —
Slot 자신이 `ownerKey`인 재귀 케이스는 `N`이 생애주기 내내 바뀌지만
그 실제 개수 자체도 보통 작아서 결론은 같음, `N`의 정확한 수명주기는
위 "저장 위치" 절 참고) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브
캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, 캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라,
`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 `Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안
일어나게 막음. 일어나게 막음.
@ -1380,6 +1422,7 @@ function Dispatch.setLength(ownerKey, i, len)
end end
bk.lengthList[i] = len bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고
local function gatedRecompute() local function gatedRecompute()
if not blocker:IsOn() then if not blocker:IsOn() then
@ -1413,6 +1456,14 @@ end
정적 자식 2개짜리도 재현됨, 트레이싱 상세는 정적 자식 2개짜리도 재현됨, 트레이싱 상세는
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절). `qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절).
**[정정, 2026-08-18 구현 전 QA 3라운드] 위 크래시는 `bk.N`이 "배치 시작
전에 이미 최종 크기로 고정"이라는, 그때 당시의 전제 위에서만 성립한다 —
그 전제 자체가 위 "저장 위치" 절에서 뒤집혔다(`bk.N`은 이제 그때그때
실제 개수). 아래 게이팅은 여전히 필요하지만, 지금은 **크래시 방지가
아니라 비용** 때문이다 — 게이팅 없이 등록마다 `recompute`가 한 번씩
돌면 O(N²), 게이팅으로 배치 끝에 한 번만 돌면 O(N). 상세는 "저장 위치"
절 참고.
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그 **해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**: 자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:

View file

@ -1497,7 +1497,23 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그 자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그
전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는 전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는
**`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야 **`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야
확정된 값으로 등록) → 물리 마운트**: 확정된 값으로 등록) → 물리 마운트**.
**[재정정, 2026-08-18 구현 전 QA 3라운드] "확정된 값으로 등록"은 값
자체가 아니라 이 순서를 지키는 한 자연히 따라오는 결과를 가리킨 표현이지,
`setLength` 호출 시점에 `slot.Length`의 **값**이 반드시 최종값이어야
한다는 뜻은 아니다.** 실체화(`activateList`)와 물리 마운트(flush 루프)의
순서를 더 트레이싱하며 `RC-3`/`RC-4`(둘 다 `activateList` 도중 아직
`self._mounted`가 안 세팅된 상태를 요구한다는 게 드러남 — 아래 코드의
"`_mounted`는 여기서 아직 세팅하지 않는다" 주석 참고)를 고치는 과정에서,
`slot.Length`가 실제로 최종값으로 안정되는 시점은 `activateList` 직후가
아니라 **flush 루프가 끝난 뒤의 마지막 `recompute`**로 한 단계 더
밀렸다 — `Dispatch.setLength(ownerKey, position, slot.Length)`가 넘기는
건 **State 객체 자신**이라, 등록 시점에 값이 아직 안 굳어 있어도
무해하다(부모는 객체를 구독해뒀다가 나중에 값이 바뀌면 정상 반응).
`setOffsetSource → setLength` 순서 자체(왜 `setOffsetSource`가 먼저여야
하는지)는 안 바뀜 — 상세 트레이싱은
`qa-request/pre-implementation-qa-round3.md``RC-3`/`RC-4` 절.
```lua ```lua
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합 -- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
@ -1509,23 +1525,52 @@ local function attachSlot(slot, physicalTarget, ownerKey, position)
-- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은 -- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은
-- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에 -- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에
-- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음. -- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음.
slot._mounted = true
slot._mountedInst = physicalTarget
local offsetSource = Source(0) local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산 Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산
slot.Offset = offsetSource slot.Offset = offsetSource
-- [재정정, 2026-08-18 구현 전 QA 3라운드, `RC-3`/`RC-4` 해결 —
-- 사용자 설계] `_mounted`는 여기서 아직 세팅하지 않는다 — 그래야
-- 아래 `activateList`가 실행되는 동안 `self._mounted`가 계속
-- `false`라, `:List`의 reconcile이 부르는 `rawAdd`가 "아직 마운트
-- 전"(= `_elements`에만 넣고 끝) 경로를 타서 이 시점엔 물리
-- 마운트도 Dispatch 등록도 전혀 안 일어난다. 옛 코드는 `_mounted`
-- 맨 위에서 세팅해뒀었는데, 그러면 reconcile의 `rawAdd`가 매 항목마다
-- 즉시 물리 마운트 + `Dispatch.setLength`를 태워(아래 flush 루프가
-- 곧 다시 처리할 바로 그 자리를) 두 가지 문제를 냈다 — (a) 아직
-- Blocker가 없어(그건 flush 루프 직전에야 생김) 매 항목마다 게이팅
-- 없이 `recompute`가 돎(`RC-3`), (b) nested Slot 항목은 이 시점에
-- 이미 `attachSlot`이 한 번 불렸는데, 아래 flush 루프가 같은 요소를
-- 다시 순회하며 `attachSlot`**또** 불러 이중 실행됨(`RC-4`).
-- `_mounted``activateList` 뒤로 미루면 이 함수 안에서 실제
-- 마운트가 일어나는 자리는 아래 flush 루프 단 하나로 통일된다 —
-- `:List`든 수동 CRUD든 구분할 필요가 없어짐. 상세 트레이싱은
-- `qa-request/pre-implementation-qa-round3.md``RC-3`/`RC-4` 절.
if slot._listed then if slot._listed then
activateList(slot, physicalTarget) -- 실체화 — 여기서 slot.Length가 진짜 값으로 확정됨 activateList(slot, physicalTarget) -- reconcile이 채우는 건 `_elements`뿐 — 물리 마운트는 안 함(위 참고)
end end
Dispatch.setLength(ownerKey, position, slot.Length) -- 실체화 뒤 — 확정된 값으로 등록, 재전파 낭비 없음 slot._mounted = true
slot._mountedInst = physicalTarget
-- [2026-08-18 신설, RC-1 해결] attach 전에 이미 들어와있던 요소들 flush — -- **[정정, 2026-08-18 3라운드]** `slot.Length`는 이 시점에 아직
-- 이 루프도 Dispatch.drive의 최상위 배열 순회와 똑같이 "slot._elements의 -- "확정된 값"이 아니다 — 최종 값은 아래 flush 루프 끝의 `recompute`
-- 개수(N)가 이미 정해진 채 position을 하나씩 등록"하는 배치라 같은 -- 매긴다. 여기서 넘기는 건 값이 아니라 **State 객체 자신**이라 무해함:
-- 크래시 위험이 있음. 이 Slot 자신의 owner 키로 별도 Blocker를 새로 -- 부모는 이 객체를 구독해뒀다가, 그 값이 나중에(flush 끝나고) 바뀌면
-- 정상적으로 다시 반응한다(부모 배치가 아직 안 끝났으면 부모 자신의
-- Blocker가 그 반응을 알아서 미룸 — 아래 "확인만 하고 새 결함 없음"
-- 절 참고). 옛 주석("확정된 값으로 등록")은 옛 순서(`_mounted`가
-- `activateList`보다 먼저라 그 안에서 이미 최종화되던 것) 기준이었고
-- 이제는 안 맞아 정정.
Dispatch.setLength(ownerKey, position, slot.Length)
-- attach 전에 이미 들어와있던 요소들(수동 CRUD로 마운트 전 `:Add()`
-- 것) **및** 방금 `activateList``_elements`에만 채워둔 `:List`
-- 결과물 — 이제 이 flush 루프가 어느 경로로 왔든 상관없이 유일한
-- 물리 마운트 지점이다. `slot._elements`의 개수(N)가 이미 정해진 채
-- position을 하나씩 등록하는 배치라 `Dispatch.drive`와 같은 크래시
-- 위험이 있음(`RC-1`) — 이 Slot 자신의 owner 키로 별도 Blocker를 새로
-- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의 -- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의
-- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용. -- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용.
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용 local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
@ -1543,10 +1588,26 @@ local function attachSlot(slot, physicalTarget, ownerKey, position)
end end
blocker:OffWithoutEmit() blocker:OffWithoutEmit()
local bk = getBookkeeping(slot) local bk = getBookkeeping(slot)
if bk then recompute(slot, bk) end if bk then recompute(slot, bk) end -- 여기서 slot.Length가 비로소 진짜 값으로 확정됨
end end
``` ```
**⚠️ [신설, 2026-08-18 3라운드 감사 후속] 좁은 엣지 케이스 — 배치 밖에서
이 Slot이 단독으로 (재)마운트되면, 부모의 `recompute`가 아직 안 굳은
`slot.Length`로 한 번 헛돌 수 있다.** `Dispatch.setLength(ownerKey,
position, slot.Length)`(위 코드)는 `slot.Length``State`라 등록 즉시
1회 실행을 동기로 태우는데, 이 `attachSlot` 호출이 `Dispatch.drive`
배치나 부모 Slot의 flush 루프 **안**이면 부모 Blocker가 아직 켜져 있어
안전하게 스킵되지만, **배치 밖**(예: `state<Slot>` 값이 steady state에서
반응형으로 교체될 때, 부모 owner의 Blocker는 이미 꺼진 채)이면 부모의
`gatedRecompute`가 즉시 실행돼 아직 flush가 안 끝난 `slot.Length`로 한
번 계산한다 — flush가 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면
정확한 값으로 자기 교정된다. 크래시도 영구적으로 틀린 값도 아니고
최악의 경우 한 프레임짜리 낭비 재계산 — 손대지 않기로 함, 다만 이
자리를 다시 만질 때 놓치지 않도록 기록. 트레이싱 원문은
`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함
없음" 절.
**최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:** **최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:**
```lua ```lua
-- process(inst, k, slotValue, index) -- process(inst, k, slotValue, index)
@ -1659,7 +1720,7 @@ function rawUnmount(self, index)
releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음) releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end
spliceArraysDown(self, index) spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
recompute(self, bk) recompute(self, bk)
end end
@ -1676,11 +1737,52 @@ function rawRemove(self, index)
-- 들어온 뒤로는 이 누락이 실동작 차이를 만듦 -- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
if isSlot(element) then destroySlotTree(element) else element:Destroy() end if isSlot(element) then destroySlotTree(element) else element:Destroy() end
spliceArraysDown(self, index) -- _elements/lengthList/sourceList 전부 한 칸씩 당김 spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
end end
``` ```
**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는
배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가
이 코퍼스 어디에도 명시된 적이 없었음.**
- **`bk.observers`도 같이 당겨야 함** — 위 코드가 이미 `bk.observers[index]`
읽어 `unbindLifetime`하지만(제거되는 그 위치의 것), `spliceArraysDown`
자신이 이동시켜야 하는 배열 목록에 지금까지 `observers`가 빠져 있었다
(`_elements`/`lengthList`/`sourceList` 셋만 언급됨). `bk.observers[i]`
`Dispatch.setLength`가 그 자리 length가 `State`일 때만 채우는(위
"`setLength` 구현" 절) position-indexed 배열이라, 나머지 셋과 똑같이
뒤 position들이 한 칸씩 당겨질 때 같이 안 당기면 이후 그 position의
observer가 엉뚱한 것(옛 이웃의 observer)을 가리키게 된다.
- **`bk.N`도 여기서 하나 줄여야 함** — `recompute`(`base/dispatch-core-plan.md`
"Length/Offset" 절)가 `for i = 1, bk.N do`로 순회하는 그 상한. **`bk.N`
정의 자체가 이 코퍼스 어디에도 없던 갭**이었다(`qa-request/
pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기" 절 — **사용자
확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될
때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에
동일하게 적용"*). 즉 `bk.N``Dispatch.setLength`가 이전에 본 적
없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는
건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔
`lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`attachSlot`의 flush
배치도, Slot의 런타임 단건
`rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를
물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive`
`inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은
구조적으로 늘거나 줄지 않으므로(전체 재-dispatch만 있음) `bk.N`
등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은
규칙의 특수한 안정 상태다.
- **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/
`attachSlot`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker
게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) —
그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도
안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에
이미 최종 크기로 고정돼 있었던 것**의 부산물이었다는 게 이번에 다시
확인됨 — 지금은 그 전제 자체가 없다. 그 대신 Blocker 게이팅이 여전히
필요한 이유는 크래시 방지가 아니라 **비용**(등록마다 `recompute`
한 번씩 도는 O(N²) 대신 배치 끝에 O(1)번만) — `RC-1` 해결 논의에서
사용자가 직접 지적한 "이러면 첫 실행에서 계속 recompute 비용이 쌓임"
문제 그대로.
**왜 `unbindLifetime`이 꼭 필요한지**: `bindLifetime`은 물리 target **왜 `unbindLifetime`이 꼭 필요한지**: `bindLifetime`은 물리 target
인스턴스 생명주기에 걸려있는데, 죽는 건 "이 nested Slot 하나"고 물리 인스턴스 생명주기에 걸려있는데, 죽는 건 "이 nested Slot 하나"고 물리
target(공유 부모)은 계속 살아있으니 GC가 자동으로 안 치워줌 — 명시적으로 target(공유 부모)은 계속 살아있으니 GC가 자동으로 안 치워줌 — 명시적으로

View file

@ -71,8 +71,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
`type-recursive-issue-try-callback/` 등). `type-recursive-issue-try-callback/` 등).
- `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을 - `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을
담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도
여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md` 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/
둘 다 **완료** — 라운드마다 새 파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, `pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새
파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
**[2026-08-18 기준] 폴더 자체가 아직 없음**. **[2026-08-18 기준] 폴더 자체가 아직 없음**.
`.claude/archive/`는 원래 같은 취급이었으나 `.claude/archive/`는 원래 같은 취급이었으나
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전

View file

@ -0,0 +1,422 @@
# 구현 전 QA **3라운드** — Blocker/`attachSlot`/`recompute` 손 트레이싱
**상태**: **완료 — `RC-3`/`RC-4`/`bk.N` 전부 사용자 확정을 거쳐 `base/`
반영 완료.** 2라운드가 발견·해결한 `RC-1`(Blocker 게이팅) 반영분을
대상으로, 이번엔 `attachSlot`이 그 게이팅을 실제로 어떻게 쓰는지와
`recompute`가 의존하는 `bk.N`의 수명주기를 손으로 실행해봤다. 부수로
`ROADMAP.md` 마일스톤 분할이 이번 라운드 발견과 맞는지도 검토했다
(1라운드가 미룬 항목).
**⚠️ 이 문서를 읽을 때 주의 — 아래 문제 서술 중 일부는 최초 작성 당시의
분석 오류를 포함한 채 그대로 남아 있다(의도적으로 안 고침, 논의 과정
보존).** 특히 `bk.N` 절의 "(a)/(b) 두 갈래 다 깨진다"는 최초 분석은
**틀렸다** — 사용자가 직접 지적해 정정됐다("해결 — `bk.N`" 절 참고).
지금 유효한 결론은 각 절의 "해결" 소제목 아래만 — 그 위 문제 서술은
"당시엔 이렇게 봤다"는 트레이싱 원문으로만 읽을 것.
**이 문서의 용도**: 2라운드와 같은 톤 — 트레이싱 결과 자체가 산출물이고,
버그는 그 자리에서 기록, 방향이 갈리는 것만 사용자에게 물었다.
---
## RC-3 — `activateList`가 자기 Slot의 Blocker보다 먼저 실행됨
**대상**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot` 의사코드.
**순서를 그대로 읽으면**:
```lua
slot._mounted = true -- (1) 이 시점부터 이미 "마운트됨"
...
if slot._listed then
activateList(slot, physicalTarget) -- (2) reconcile 실행 — 아직 Blocker 없음
end
Dispatch.setLength(ownerKey, position, slot.Length) -- (3)
local blocker = getBlocker(slot) -- (4) Blocker가 여기서야 생성됨
blocker:On()
for i, element in ipairs(slot._elements) do ... end -- (5)
blocker:OffWithoutEmit()
```
**문제**: (1)에서 `slot._mounted = true`가 이미 세팅된 채로 (2)의
`activateList`가 실행된다. `activateList``reconcile`은 새 항목마다
`rawAdd(self, result, pos)`를 부르는데(`slot-plan.md`의 `:List` "구현"
절), "이미 마운트된 outer에 나중에 `Add`" 절이 명시하듯 `self._mounted`
참이면 `rawAdd`는 그 자리에서 즉시 물리 마운트 경로를 탄다 —
`isSlot(element)``attachSlot(element, ...)` 재귀, 아니면(대칭적으로)
`Dispatch.setOffsetSource(self,index,None)` + `Dispatch.setLength(self,index,1)`
+ `element.Parent = physicalTarget`. `Dispatch.setLength`는 끝에서
`gatedRecompute`를 부르고, `gatedRecompute``getBlocker(ownerKey=self)`
`IsOn()`을 확인하는데 — **(4)의 `getBlocker(slot):On()`이 아직 실행되기
전이므로, 새로 생성된 Blocker는 기본 off 상태고 게이트가 그냥 통과된다.**
`:List`의 초기 reconcile이 채우는 **모든 항목마다** `recompute`
그 자리에서 즉시(게이팅 없이) 돈다 — 이건 정확히 `RC-1`이 막으려던
"배치 등록 중 매 position마다 recompute가 도는" 모양이다. 사용자가
`RC-1` 논의에서 직접 지적한 문제("이러면 첫 실행에서 계속 recompute
비용이 쌓임")가 `:List`의 초기 population 경로에서는 그 처방이 적용되기
**전** 자리에서 그대로 재현된다.
**크래시로 이어지는지는 `bk.N`에 달려 있다** — 아래 "`bk.N` 수명주기"
절 참고. `bk.N`이 이 시점에 아직 `0`(또는 `nil`)이면 크래시는 안 나고
그냥 매 항목마다 무의미한 `recompute(self,bk)` 호출만 쌓인다(루프가
`for i=1,0`이라 즉시 반환). `bk.N`이 최종 개수로 미리 정해져 있는
모델이면 `RC-1`과 완전히 같은 모양(뒤쪽 `lengthList[i]`가 아직 `nil`)의
크래시가 난다.
---
## RC-4 — flush 루프가 `:List`로 이미 마운트된 요소를 다시 처리함
**같은 `attachSlot`에서 이어지는 문제**: (5)의 flush 루프
(`for i, element in ipairs(slot._elements) do ... end`)는 `slot._listed`
여부를 확인하지 않고 **항상** 돈다. 그런데 `_listed`/`_crudUsed`는 상호
배타(`slot-plan.md`의 "CRUD API 확정" 절 — `_crudUsed`/`_listed` 역방향
가드)이므로, `:List`가 설치된 Slot의
`_elements`는 수동 `:Add()`가 아니라 **오직 (2)의 `activateList`
채운 것뿐**이다 — 그리고 위 `RC-3`에서 확인했듯 그 채움 과정 자체가
이미 각 항목을 물리적으로 마운트(`element.Parent = physicalTarget`)하고
`Dispatch.setOffsetSource`/`setLength`를 등록까지 마친 상태다.
flush 루프는 이 사실을 모르고 **같은 요소들을 처음 보는 것처럼** 다시
처리한다 — `Dispatch.setOffsetSource(slot, i, None)`/
`Dispatch.setLength(slot, i, 1)`을 중복 호출하고, 이미 부모에 붙어있는
`element``element.Parent = physicalTarget`를 다시 대입한다(Roblox라면
`AncestryChanged`가 불필요하게 한 번 더 발화). 값 자체는(멱등하게) 결국
맞게 수렴하겠지만, `:List`가 nested Slot을 요소로 반환한 경우
(`isSlot(element)`)는 **`attachSlot(element, physicalTarget, slot, i)`
통째로 두 번 실행**된다 — 이건 멱등하지 않다: `slot._mounted = true`
다시 세팅하는 정도는 무해해 보여도, "마운트된 Slot의 재마운트는 즉시
throw" 규칙(`slot-plan.md` "마운트된 Slot의 재마운트는 즉시 throw" 절)에
비춰보면 **nested Slot이 자기 자신을 향해 재귀적으로 이미 마운트된
채로 다시 `attachSlot`되는 것 자체가 그 규칙이 막으려는 상황과 같은
모양**이라, 최소한 이 규칙과의 정합성을 다시 검토해야 한다.
**추정 원인**: flush 루프의 주석("attach 전에 이미 들어와있던 요소들
flush")이 밝히듯, 이 루프는 **수동 CRUD로 마운트 전에 `:Add()`
요소**만 염두에 두고 `RC-1` 해결 과정에서 추가된 것 — `:List` 케이스가
같은 함수를 통과한다는 걸 놓친 것으로 보인다(`RC-1` 자체는
`Dispatch.drive`/`attachSlot`의 flush 두 자리만 위험하다고 확인했고,
`activateList`는 그 확인 대상에 없었다).
**참고**: 이 두 결함(`RC-3`/`RC-4`)은 정확한 크래시/오작동 심각도가
`bk.N`의 수명주기에 좌우되므로, 아래 질문의 답이 나온 뒤 같은 자리에서
같이 고치는 게 맞아 보인다(둘 다 "`_listed`면 flush 루프를 건너뛰고,
`activateList` 자체를 `blocker:On()`/`OffWithoutEmit()`으로 감싼다"는
같은 방향의 수정으로 닫힐 가능성이 높음 — 다만 이건 제안이지 확정
아님, 사용자 확인 필요).
### 해결 — `_mounted``activateList` 뒤로 미룸 (2026-08-18, 같은 세션 후속, 사용자 설계)
위에서 제안했던 "`_listed`면 flush 루프를 건너뛴다"는 **채택 안 됨**
사용자가 더 단순한 대안을 직접 제시했다:
> if not slot._listed then ... end 로 감싸면 안 되는거 아닌가요? 그냥
> _mounted 를 activateList 아래 두는게 안되는 이유가 있어요? 만일,
> 그렇게 감싼다면 그건 blocker 를 안 타니까요. 그리고 또, attachSlot 은
> 런타임 상 발생할 수 있는게 맞긴 하죠? 왜냐면, 안 그러면 List 에서
> Frame 만 던질 수 있어요. nested slot 을 던지는 컴포넌트는 사용
> 못하게 될텐데요.
**`slot._mounted = true`/`slot._mountedInst = physicalTarget`를
`attachSlot` 맨 위에서 `activateList` 호출 **뒤**(flush 루프 바로 전)로
옮기면 `RC-3`/`RC-4`가 한 번에 닫힌다**:
- `activateList`가 실행되는 동안 `self._mounted`가 계속 `false`이므로,
`:List`의 reconcile이 부르는 `rawAdd`는 "아직 마운트 전" 경로
(`_elements`에만 넣고 물리 마운트/Dispatch 등록은 안 함,
`slot-plan.md`가 이미 "self가 아직 마운트 전이면 _elements에만
들어가고, self가 나중에 attachSlot될 때 위 flush 루프가 처리"로
명시해둔 바로 그 경로)를 탄다. → `RC-3`(항목마다 무게이팅
`recompute`) 자체가 안 생김 — flush 루프 전엔 어떤 Dispatch 등록도
없으므로.
- flush 루프가 `slot._elements`(이제 `:List`든 수동 CRUD든 항상 여기에만
쌓여 있음)를 순회하며 **처음이자 유일하게** 각 요소를 물리
마운트한다 — nested Slot이면 `attachSlot`도 여기서 **딱 한 번만**
불린다. → `RC-4`(이중 실행) 자체가 안 생김. `_listed` 분기가 필요
없어짐 — flush 루프가 두 경로(`:List`/수동 CRUD) 모두에 대해 이미
동일하게 옳은 유일한 마운트 지점이 됨.
- **`rawAdd``self._mounted` 즉시-마운트 분기 자체는 그대로 남는다** —
사용자가 확인한 대로 이건 삭제 대상이 아니라 **런타임에 실제로 필요한
경로**다: `attachSlot`으로 최초 마운트가 끝난 **뒤**(예: `data`
나중에 바뀌어 `:List`의 reconcile이 다시 실행될 때) `self._mounted`
이미 `true`이므로, 그 시점에 새로 추가되는 nested Slot 항목은 이
분기를 통해 정상적으로 즉시 `attachSlot`된다 — 그래서 `:List`
nested Slot을 반환하는 컴포넌트를 계속 지원한다. 이번에 바뀐 건 오직
"`attachSlot` 자기 자신의 **최초** flush 이전엔 이 분기가 안 타야
한다"는 타이밍 하나뿐.
**반영**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot`
의사코드(`_mounted` 위치 이동 + 주석), `base/dispatch-core-plan.md`
"저장 위치"/"배치 등록을 안전하게 만드는 Blocker 게이팅" 절, `base/
blocker-plan.md`의 "두 번째 용례" 절(아래 `bk.N` 해결과 같이 반영).
---
## `bk.N`의 수명주기가 명세에 없음 — 판단 필요
`recompute`(`base/dispatch-core-plan.md` "Length/Offset" 절)는
`for i = 1, bk.N do`로 순회한다. `bk.N`의 정의는 문서에 **딱 한 곳**뿐:
> **저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그
> `inst`의 array part 크기 `N`으로 같이 저장, `Dispatch.drive`가 최초
> 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`
> lazy 생성.
이건 **`Dispatch.drive`가 순회하는 최상위 `inst`** 전용 서술이다 —
그 경우 `N`은 저작 시점에 고정된 배열 리터럴 길이라 정말로 "한 번 알면
끝"이다. 그런데 `base/slot-plan.md`의 "재귀 메커니즘" 절이 **같은
`recompute`/`getBookkeeping`을 Slot 자신을 ownerKey로 재사용**하면서
(`Dispatch.setLength`/`setOffsetSource`가 "owner 키(`inst`)가 물리
Instance일 필요가 없음"을 근거로), Slot의 경우 `bk.N`이 무엇이고 언제
갱신되는지는 **어디에도 안 적혀 있다**. `getBookkeeping`/
`spliceArraysDown` 자체도 이 코퍼스 전체에서 정의된 적이 없는(호출만
되는) 헬퍼다(grep 확인, `bk.N =` 대입 자체가 코퍼스에 0건).
**왜 이게 그냥 구현 디테일이 아니라 지금 결정이 필요한가**: `Dispatch.drive`
`inst`와 달리, **Slot의 자식 개수는 Slot 전체 생애주기 동안 계속
바뀐다**(그게 Slot의 존재 이유) — "한 번 알면 끝"이라는 `inst` 쪽 전제가
Slot에는 애초에 성립하지 않는다. 두 갈래 다 손으로 트레이싱해보면 각각
다른 방식으로 깨진다:
**(a) `bk.N`이 "고정값"이라면(배치 시작 시 저장, 이후 안 바뀜)**:
`rawRemove`(`slot-plan.md` "파괴" 절)를 트레이싱하면 —
```lua
function rawRemove(self, index)
...
spliceArraysDown(self, index) -- _elements/lengthList/sourceList 한 칸씩 당김
recompute(self, bk) -- bk.N은 그대로(감소 안 함)
end
```
`spliceArraysDown`이 배열을 한 칸씩 당기고(마지막 자리는 비거나 stale
복제값으로 남음, 정의가 없어 어느 쪽인지도 불명) `bk.N` 자체를 줄이지
않으므로, **2개짜리 Slot에서 요소 하나를 `Remove`하기만 해도** 다음
`recompute``for i=1,bk.N(=2)`가 이제 존재하지 않는 위치 2를 읽는다
`spliceArraysDown`이 그 자리를 `nil`로 비운다면 `RC-1`과 정확히 같은
`sum += nil` 산술 에러, 옛 값을 그대로 둔 복제라면 그 값을 이중으로
합산하는 조용한 오계산이다. 어느 쪽이든 **가장 흔한 조작(2개 이상인
Slot에서 하나 제거)에서 매번 재현**된다 — `RC-1`이 "정적 자식 2개짜리
`Frame`에서도 재현"이라고 짚었던 것과 같은 급의 흔함.
**(b) `bk.N`이 "그때그때 실제 개수"라면(예: `#ownerKey._elements`로 매번
파생, 또는 매 `setLength`/`spliceArraysDown` 호출마다 갱신)**: 위
`rawRemove` 크래시는 없어진다. 대신 마운트 시점 배치(`Dispatch.drive`
최상위, `attachSlot`의 flush)에서 `RC-1`이 막으려던 **바로 그 크래시가
되돌아온다** — 배치 도중 `bk.N`이 이미 등록된 position 개수만큼만
증가한 상태라면 recompute 자체는 안전해지지만(순회 범위가 실제 채워진
자리까지만), 반대로 **Blocker 게이팅이 애초에 막으려던 "배치 끝나기
전엔 recompute 안 돈다"는 전제가 필요 없어진다는 뜻**이라 — `RC-1`
해법 전체가 어떤 `bk.N` 모델을 전제하는지부터 다시 맞춰야 한다.
(자세히 보면 게이팅으로 `recompute` 호출 자체를 스킵하므로 (b) 모델이어도
크래시는 안 나지만, **배치 종료 후 딱 1회 도는 마지막 `recompute`가 이번엔
반대로 부족한 `N`을 볼 수 있다** — 예: `attachSlot` flush 루프 중간에
어떤 position의 `setLength`가 **State**를 받아 `Observer`의 "등록 즉시
1회 실행"이 배치 밖 시점까지 늦게 도착하는 경합이 있다면.)
**분기점 — 사용자 판단 필요(아래 질문 참고)**: `bk.N`을 그때그때 실제
개수로 둘지, 배치 시작 시 저장해두는 값으로 둘지에 따라 고칠 자리가
갈린다 — 전자면 `rawRemove`/`rawUnmount`/런타임 `rawAdd`가 문제,
후자면 `RC-3`/`RC-4`가 이미 지적한 자리가 문제. 어느 쪽이든 `RC-3`/
`RC-4`는 별도로 고쳐야 하지만, `bk.N` 자체의 수명주기 규칙은 이
문서가 결정하지 않는다 — 아래에서 직접 여쭤본다.
### 해결 — `bk.N` = 그때그때 실제 개수, 위 (b) 분석은 틀렸음 (2026-08-18, 같은 세션 후속, 사용자 지적)
위 (b) 갈래("`bk.N`이 그때그때 실제 개수면 마운트 배치에서 `RC-1`
막으려던 크래시가 되돌아온다")는 **분석 오류였다.** 사용자가 직접
잡아냄:
> 그때그때 실제 개수를 전부 적용하는건 안 돼? 사실 전부 똑같은
> 방법으로 구현해도 상관 없지 않아? 그리고 drive 중에는 recompute
> 안나지 않아? 계속 후행 붙이기라서 약간 다를텐
**틀렸던 지점**: `Dispatch.drive`/`attachSlot`의 배치 등록 중
`recompute`가 안 도는 이유는 **`bk.N`이 아니라 Blocker 게이팅**
(`blocker:IsOn()`만 확인하는 `gatedRecompute`)이다 — 이 게이트는
`bk.N`이 무엇이든 **전혀 상관하지 않는다**. 그러므로 `bk.N`이 배치
도중 계속 늘어나는 중이어도(아직 최종 크기가 아니어도) 배치 안에서
`recompute` 자체가 안 도니 크래시도, 부정확한 계산도 안 생긴다 —
필자가 (b)를 쓰며 "게이팅이 배치 끝나기 전엔 recompute 안 돈다는
전제가 필요 없어진다"고 적었던 건 스스로 반대 결론(게이팅이 여전히
작동 중이라는 사실)을 옆에 적어두고도 놓친 것.
**결론**: `bk.N` = **그때그때 실제 개수**로 두 owner 타입(`inst`,
Slot 자신) 모두에 동일한 규칙 적용 — `Dispatch.setLength`/
`setOffsetSource`가 이전에 없던 더 큰 position을 등록할 때마다
`bk.N`이 그 값으로 늘어나고, `spliceArraysDown`(Slot의 `rawRemove`/
`rawUnmount`)이 위치를 구조적으로 지울 때 그만큼 줄어든다. `Dispatch.drive`
`inst`에서는 최상위 배열이 구조적으로 안 바뀌므로 이 규칙이 그냥
"등록 끝나면 고정값처럼 보이는" 특수한 안정 상태가 될 뿐, 별도 모델이
필요 없다 — **두 owner 타입에 정말로 똑같은 구현**(사용자가 지적한
그대로).
**`RC-1`의 원래 크래시가 실제로 뭐였는지 다시 정리하면**: `bk.N`
"배치가 시작되기도 전에 이미 최종 크기로 고정"돼 있었던 것의 부산물
— 그 전제 자체가 이번에 사라졌다. Blocker 게이팅이 지금도 필요한
이유는 크래시 방지가 아니라 **비용**이다: 게이팅 없이 매 position
등록마다 `recompute`가 한 번씩 돌면 배치당 O(N²), 게이팅으로 배치 끝에
한 번만 돌면 O(N) — `RC-1` 최초 논의에서 사용자가 직접 지적한 "이러면
첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로.
**추가로 확인된 갭 — `spliceArraysDown`이 미는 배열 목록에 `bk.observers`
빠져 있었음.** `rawRemove`/`rawUnmount`가 제거되는 위치의
`bk.observers[index]``unbindLifetime`하긴 하지만, `spliceArraysDown`
자신이 밀어야 할 배열로 지금까지 `_elements`/`lengthList`/`sourceList`
셋만 서술돼 있었다 — `observers`도 같이 밀지 않으면 이후 그 위치의
observer가 옛 이웃 것을 계속 가리키게 된다. `base/slot-plan.md`
반영.
**반영**: `base/dispatch-core-plan.md`의 "저장 위치" 절(`bk.N`
수명주기 정의 신설), "배치 등록을 안전하게 만드는 Blocker 게이팅"
절의 "문제 재확인" 문단(크래시 전제가 바뀌었다는 정정 추가),
`base/slot-plan.md``spliceArraysDown`/`rawRemove`/`rawUnmount`
근처(`bk.observers`/`bk.N` 갱신 명문화), `base/blocker-plan.md`
"두 번째 용례" 절(같은 정정), `ROADMAP.md` M2 체크박스(같은 정정 +
M2/M3 교차 의존 각주).
---
## 확인만 하고 새 결함 없음 — 재검증
- **중첩 Slot의 `Length:Set`이 부모 Blocker가 켜져 있는 동안 나가는
경우** — `attachSlot`이 재귀로 `attachSlot(element, physicalTarget,
slot, i)`를 부를 때, 안쪽 재귀도 자기 자신의 `getBlocker(element)`
별도 Blocker를 새로 만들어(부모 Blocker와 무관) 자기 flush를 감싼다.
안쪽 재귀 끝의 `recompute(element, bk)``element.Length:Set(sum)`
호출하면, 이건 **부모 쪽 관점에서 보면 `Dispatch.setLength(parentSlot,
i, element.Length)`가 이미 등록해둔 그 State 객체의 값이 바뀌는 것** —
부모의 `gatedRecompute`가 이 변화를 받지만, 부모의 Blocker가 아직
켜져 있으면(외곽 배치가 안 끝났으면) 정상적으로 스킵되고, 부모 배치가
끝난 뒤 마지막 `recompute(parentSlot, parentBk)` 한 번에 자연스럽게
반영된다 — 설계 의도대로 동작, 새 문제 없음.
- **`getBlocker`의 lazy 생성 기본값이 off라는 전제가 런타임 단건 경로를
성립시킴** — "이미 마운트가 된 이후"의 단건 `:Add()`가 게이팅 없이
바로 `gatedRecompute`를 태우는 게 안전한 이유는, 그 시점 Blocker가
(flush 때 만들어져 `OffWithoutEmit()`으로 꺼진 채 남아있으므로) 항상
off 상태이기 때문 — 트레이싱으로 재확인, 새 발견 아님.
- **⚠️ [신설, 반영 후 자체 재검토] 재정렬로 새로 생긴 좁은 엣지 케이스 —
배치 밖(steady state)에서 Slot이 단독으로 (재)마운트될 때, 부모의
`recompute`가 아직 안 굳은 `slot.Length` 값으로 한 번 먼저 돌 수
있음.** `Dispatch.setLength(ownerKey, position, slot.Length)`(위 해결
절의 재정렬 뒤 코드)은 `slot.Length``State``Observer` "등록 즉시
1회 실행"을 그 자리에서 동기로 태운다 — 이게 부모의 `gatedRecompute`
부르는데, **이 Slot 마운트가 `Dispatch.drive`의 배치나 부모 Slot의
flush 루프 **안**이면** 부모의 Blocker가 아직 켜져 있어 안전하게
스킵되지만(트레이싱 확인, 새 결함 아님), **배치 밖에서 이
`attachSlot`이 단독으로 불리는 경우**(예: `state<Slot>` 값이 steady
state에서 반응형으로 교체돼 재-dispatch되는 경우, 부모 owner의
Blocker는 이미 예전에 `OffWithoutEmit()`으로 꺼진 채)엔 부모의
`gatedRecompute`가 즉시 실행돼, 아직 flush가 안 끝나 최종값이 아닌
`slot.Length`로 부모가 한 번 (헛되이) 재계산한다 — 뒤이어 flush가
끝나고 `slot.Length:Set(최종값)`이 다시 발화하면 부모가 다시 정확하게
재계산해 값 자체는 스스로 바로잡힌다. **크래시도 영구적으로 틀린
값도 아니고**, `Get()~=sum` 가드 때문에 실제로 `:Set`이 두 번 나가는
것도 조건부(첫 번째 계산이 우연히 맞을 수도 있음)라 — Roblox 기준
최악의 경우 한 프레임짜리 낭비 재계산 정도. 재정렬 이전 코드(`_mounted`가
`activateList`보다 먼저)에는 이 경로 자체가 없었음(`slot.Length`가
이미 등록 시점에 확정돼 있었으므로) — 그래서 완전히 새로 생긴 특성.
**크래시급이 아니라 이 라운드를 다시 열진 않지만, 다음에 이 자리를
만지는 세션이 알아야 할 사실로 기록.**
- **`Dispatch.drive` 자신은 코드 블록이 없다** — 이 문서 전체에서
`Dispatch.drive`는 항상 산문으로만 서술되고(`Dispatch.drive(inst,
flattened)`가 배열→해시 두 패스로 `Dispatch.process`를 부른다는 것),
Blocker 게이팅을 그 함수 **자신**이 어떻게 여닫는지 보여주는 의사코드는
없다(`attachSlot`만 실제 코드로 있음). 버그는 아님 — `Dispatch.drive`
자체가 이 코퍼스 어디에도 전체 코드로 나온 적이 없어서(항상 서술뿐),
이번에 새로 생긴 갭이 아니라 원래부터 그랬던 문서화 수준의 차이일
뿐이다. 실제 구현 시(M2) `attachSlot`과 같은 패턴(자기 owner=inst의
Blocker를 `:On()` → 배열 파트 순회 → `:OffWithoutEmit()`
`recompute` 1회)으로 쓰면 될 걸로 보이나, 코드로 명문화돼 있지 않다는
점만 기록.
---
## `ROADMAP.md` 마일스톤 정합성 — 새 불일치 발견
1라운드가 "다음에 검토"로 미뤄뒀던 항목. 이번 라운드는 `RC-1`의 Blocker
게이팅 해법이 실제로 마일스톤 순서와 맞물리는지를 봤다.
**문제 — M2가 M3의 산출물(`Blocker`)에 구조적으로 의존하게 됐다.**
`ROADMAP.md` M2(디스패치 엔진)의 `Dispatch.setLength`/`setOffsetSource`
체크박스(90번대 줄)는 이렇게 적혀 있다:
> **[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음
> 채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로
> 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 `recompute`
> 미루고 배치가 끝나면 명시적으로 한 번만 돎
즉 M2 체크박스 자체가 "`setLength`/`setOffsetSource`를 구현하려면
`Blocker`가 있어야 한다"고 명시한다. 그런데 `Blocker.luau`는 M3
체크박스(`## M3 — Store/State/Source` 절)에 있고, 그 근거는:
> `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에
> 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와
> 밀접히 연관돼 있어 같은 마일스톤에서 개발)
이 근거("State와 밀접히 연관돼 있어서")는 `RC-1` 이전의 오래된 이유
그대로다(`base/blocker-plan.md` 자신도 "store 개발(M3)과 밀접하게
연관됨... 별도 파일로 두되 State와 같은 마일스톤에서 함께 구현할 것"이라고
써 있음, `.claude/todos.md` 4번의 "M3에서 `Blocker`를 구현할 때"도 동일).
`RC-1`로 생긴 **M2 → Blocker** 의존은 그 뒤에 어디에도 반영이 안 됐다 —
M2가 M3보다 먼저 오는 로드맵 순서상, **M2를 그대로 순서대로 구현하면
아직 존재하지 않는 `Blocker.luau`를 참조하게 된다.**
이건 2라운드가 확인한 "`RC-1` 언급이 텍스트로는 반영됐는가"(반영됨,
확인 완료)와는 다른 질문 — **텍스트는 맞는데 그 텍스트가 만드는
마일스톤 간 순서 요구가 로드맵 구조와 어긋난다.**
**참고로 M6(Slot)의 두 자리(368번대 줄 근처)는 이미 "위 M2 항목 참고"로
정확히 교차 참조돼 있어 문제 없음** — M6는 M3보다 뒤라 Blocker가 이미
존재한다는 전제가 깨지지 않는다. 문제는 오직 M2 하나.
**선택지는 여기서 결정하지 않는다** — 가능한 방향만 짚어둔다(사용자
판단 필요):
1. `Blocker.luau`(또는 그 최소 부분집합 — `On`/`Off`/`IsOn`/
`OffWithoutEmit`만)를 M2로 옮기거나 M2 시작 부분에 선행 항목으로 추가.
2. M2 체크박스에 "M3의 `Blocker.luau`를 먼저(또는 병행) 구현해야 함"이라는
명시적 순서 각주를 달아, 로드맵 순서 자체는 유지하되 M2 착수 시
이 사실을 놓치지 않게 한다.
3. M2/M3 마일스톤 경계를 재검토(예: Blocker를 M2로 통째로 승격) —
가장 큰 변경이라 신중히.
**임시로 2번(각주) 채택** — 마일스톤 경계 자체를 바꾸는 1/3번은
설계·일정에 영향이 가는 결정이라 사용자 확인 없이 고르지 않았다. 2번은
로드맵 구조를 안 바꾸면서 "M2가 M3의 산출물에 기대고 있다"는 사실만
빠짐없이 남기는 가장 보수적인 조치라 우선 적용해뒀음(`ROADMAP.md` M2
체크박스) — 1/3번을 원하면 언제든 다시 정리 가능, 아직 최종 확정
아님.
---
## 진행 로그
**3라운드(2026-08-18) — `attachSlot`/`recompute`/Blocker 게이팅 손
트레이싱, `ROADMAP.md` 마일스톤 정합성 재검토, 같은 세션에 전부 해결·
`base/` 반영까지 완료.** 발견 순서대로:
1. `RC-3`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행돼 항목마다
무게이팅 `recompute`가 도는 것으로 보였음)와 `RC-4`(flush 루프가
`:List`로 이미 마운트된 요소를 중복 처리 — nested Slot이면 이중
`attachSlot`)를 발견.
2. `bk.N`(순회 상한)의 수명주기가 문서 어디에도 없다는 것도 발견, 최초
분석은 "고정값/그때그때 실제 개수 두 갈래 다 각기 다른 방식으로
깨진다"고 판단해 사용자에게 물음.
3. **사용자가 그 분석 자체를 정정** — Blocker 게이팅은 `bk.N`이 아니라
`blocker:IsOn()`만 보므로, "그때그때 실제 개수" 모델이 배치 중
크래시를 되돌린다는 결론은 틀렸음을 지적("그때그때 실제 개수를 전부
적용하는건 안 돼? ... 그리고 drive 중에는 recompute 안나지 않아?").
`bk.N` = 그때그때 실제 개수로 두 owner 타입에 동일 적용 확정.
4. `RC-3`/`RC-4`도 사용자가 더 단순한 해법을 직접 제시 — flush 루프를
`_listed`로 분기하는 대신, `attachSlot``slot._mounted = true`
`activateList` 호출 **뒤**로 옮기는 것 하나로 둘 다 닫힘("_mounted
를 activateList 아래 두는게 안되는 이유가 있어요?").
5. 부수로 `spliceArraysDown`이 밀어야 할 배열 목록에 `bk.observers`
빠져 있던 것도 같이 발견·반영.
6. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 구조적으로 의존하게 된
불일치는 각주로 반영(가장 보수적인 조치, 마일스톤 재편 여부는 열림).
**반영 완료**: `base/slot-plan.md`(`attachSlot` 의사코드 재작성,
`spliceArraysDown`/`bk.N`/`bk.observers` 명문화), `base/
dispatch-core-plan.md`(`bk.N` 수명주기 신설, 크래시 전제 정정),
`base/blocker-plan.md`(게이팅 존재 이유 정정), `ROADMAP.md`(M2 체크박스
정정 + M2/M3 교차 의존 각주). `python3 .claude/tools/doc-check.py`
ERROR 0 확인 완료.

View file

@ -114,6 +114,17 @@
## 3. 낮은 우선순위 — 열려 있지만 급하지 않음 ## 3. 낮은 우선순위 — 열려 있지만 급하지 않음
- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`
구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지,
`Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로
앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법
때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가
`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작
`Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직
없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔
임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전
필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
- **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,
같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등 같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등
`:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level `:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level

View file

@ -5,8 +5,8 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등). (`.claude/question.md`, `luau-test/STATUS.md` 등).
00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2라운드 전부 00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2·3라운드
`base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를 전부 `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를
문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은 문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은
`.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로 `.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로
적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정 적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정
@ -20,12 +20,37 @@
Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다 Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다
(`archive/question-resolved.md`의 `RC-1` 절). (`archive/question-resolved.md`의 `RC-1` 절).
**M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다**(M0/M2는 여전히 **3라운드(완료, `.claude/qa-request/pre-implementation-qa-round3.md`
막혀 있지 않음, 0번 항목 참고) — 대부분 `question.md` 3번에도 올라가 소스) — `RC-1` 해법이 실제로 `attachSlot`에 반영된 걸 트레이싱하다
새 문제 발견, 같은 세션에 전부 해결·반영까지 완료.** 처음엔 `activateList`
자기 Slot의 Blocker가 켜지기 **전에** 실행돼 `:List` 초기 population이
문제(`RC-3`/`RC-4`)를 낸다고 봤고, `recompute`가 의존하는 `bk.N`(순회
상한)의 수명주기도 문서에 없어 "고정값/그때그때 실제 개수 둘 다 각기
다른 방식으로 깨진다"고 판단했으나 — **사용자가 이 분석 자체를
정정**했다: Blocker 게이팅은 `bk.N`이 아니라 `blocker:IsOn()`만 보므로
"그때그때 실제 개수" 모델이 배치 크래시를 되돌린다는 결론은 틀렸었다
(`bk.N` = 그때그때 실제 개수로 확정). `RC-3`/`RC-4`도 사용자가 더
단순한 해법을 직접 제시 — flush 루프를 분기하는 대신 `attachSlot`
`slot._mounted = true``activateList` 호출 뒤로 옮기는 것 하나로
둘 다 닫힘. 부수로 `spliceArraysDown`이 밀어야 할 배열에
`bk.observers`가 빠져 있던 것도 발견·반영, `ROADMAP.md` M2가 M3의
`Blocker.luau`에 구조적으로 의존하게 된 것도 각주로 반영(마일스톤
재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md
마일스톤 정합성" 절 참고).
**아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀
있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야
하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md`
3번에도 올라가
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측
확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음, 확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음,
여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다: 여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다:
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
전 필요**. `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.**
- **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) —

View file

@ -190,7 +190,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔
`recompute`를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는 `recompute`를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는
`base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker
게이팅" 절. 게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는
`bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가
바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker`
(`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만
호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에
있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는
`Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면
(`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행)
만들 필요가 있는지 사용자 판단 필요 —
`qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤
정합성" 절.
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이