quad/.claude/qa-request/pre-implementation-qa-round3.md
qwreey-agent-selene 96c8c2eaa1
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>
2026-08-19 00:07:30 +09:00

28 KiB

구현 전 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 의사코드.

순서를 그대로 읽으면:

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가 실행된다. activateListreconcile은 새 항목마다 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를 부르고, gatedRecomputegetBlocker(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)을 중복 호출하고, 이미 부모에 붙어있는 elementelement.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()으로 감싼다"는 같은 방향의 수정으로 닫힐 가능성이 높음 — 다만 이건 제안이지 확정 아님, 사용자 확인 필요).

해결 — _mountedactivateList 뒤로 미룸 (2026-08-18, 같은 세션 후속, 사용자 설계)

위에서 제안했던 "_listed면 flush 루프를 건너뛴다"는 채택 안 됨 — 사용자가 더 단순한 대안을 직접 제시했다:

if not slot._listed then ... end 로 감싸면 안 되는거 아닌가요? 그냥 _mounted 를 activateList 아래 두는게 안되는 이유가 있어요? 만일, 그렇게 감싼다면 그건 blocker 를 안 타니까요. 그리고 또, attachSlot 은 런타임 상 발생할 수 있는게 맞긴 하죠? 왜냐면, 안 그러면 List 에서 Frame 만 던질 수 있어요. nested slot 을 던지는 컴포넌트는 사용 못하게 될텐데요.

slot._mounted = true/slot._mountedInst = physicalTargetattachSlot 맨 위에서 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) 모두에 대해 이미 동일하게 옳은 유일한 마운트 지점이 됨.
  • rawAddself._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.driveinst와 달리, Slot의 자식 개수는 Slot 전체 생애주기 동안 계속 바뀐다(그게 Slot의 존재 이유) — "한 번 알면 끝"이라는 inst 쪽 전제가 Slot에는 애초에 성립하지 않는다. 두 갈래 다 손으로 트레이싱해보면 각각 다른 방식으로 깨진다:

(a) bk.N이 "고정값"이라면(배치 시작 시 저장, 이후 안 바뀜): rawRemove(slot-plan.md "파괴" 절)를 트레이싱하면 —

function rawRemove(self, index)
    ...
    spliceArraysDown(self, index)   -- _elements/lengthList/sourceList 한 칸씩 당김
    recompute(self, bk)             -- bk.N은 그대로(감소 안 함)
end

spliceArraysDown이 배열을 한 칸씩 당기고(마지막 자리는 비거나 stale 복제값으로 남음, 정의가 없어 어느 쪽인지도 불명) bk.N 자체를 줄이지 않으므로, 2개짜리 Slot에서 요소 하나를 Remove하기만 해도 다음 recomputefor 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의 setLengthState를 받아 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.driveinst에서는 최상위 배열이 구조적으로 안 바뀌므로 이 규칙이 그냥 "등록 끝나면 고정값처럼 보이는" 특수한 안정 상태가 될 뿐, 별도 모델이 필요 없다 — 두 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.mdspliceArraysDown/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.LengthStateObserver "등록 즉시 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 기준 최악의 경우 한 프레임짜리 낭비 재계산 정도. 재정렬 이전 코드(_mountedactivateList보다 먼저)에는 이 경로 자체가 없었음(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로 분기하는 대신, attachSlotslot._mounted = trueactivateList 호출 로 옮기는 것 하나로 둘 다 닫힘("_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 확인 완료.