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:
parent
b419d8c5e5
commit
96c8c2eaa1
10 changed files with 705 additions and 30 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 확인/결정 필요 목록
|
# 확인/결정 필요 목록
|
||||||
|
|
|
||||||
|
|
@ -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 핸들도 생기지 않는다 —
|
||||||
|
|
|
||||||
|
|
@ -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)**:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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가 자동으로 안 치워줌 — 명시적으로
|
||||||
|
|
|
||||||
|
|
@ -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 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전
|
||||||
|
|
|
||||||
422
.claude/qa-request/pre-implementation-qa-round3.md
Normal file
422
.claude/qa-request/pre-implementation-qa-round3.md
Normal 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 확인 완료.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`) —
|
||||||
|
|
|
||||||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -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 체크 없이
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue