From f36bdcbe5d7468157d4333e945b285ab33e38810 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 21 Aug 2026 10:47:45 +0900 Subject: [PATCH] =?UTF-8?q?design:=20QA=204=EB=9D=BC=EC=9A=B4=EB=93=9C=202?= =?UTF-8?q?=EC=B0=A8=20=ED=9A=8C=EC=8B=A0=20=EB=B0=98=EC=98=81=20=E2=80=94?= =?UTF-8?q?=20B=EC=A0=88=20=ED=99=95=EC=9D=B8=EB=B6=84=20+=20C=EC=A0=88=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=207=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B절(설명 보강 재질문) 전부 확인됨. 확인 과정에서 나온 보강: - B-1: (A) 분기는 "교체"라 stack-down이 아니고 retractFrom만 스택을 역순으로 푼다는 구분을 명시. "자기 아래는 이미 정리된 뒤" 보장도 retractFrom 한정이고, (A)에서 아래가 살아 있는 게 깜빡임 없는 갈아끼우기의 근거. - B-3: 고아 체인이 실제로 어떻게 생기는지 가상 위반 예시(MaybeWrapHandler) 추가. - B-4: "Brand는 데이터 타입에 부작용 없이 런타임 명시 타이핑을 하기 위한 것"을 존재 이유로 명시하고, duck-typing 기각 근거를 정확성/안전성 둘로 분리. C절 결정 반영: - C-3: flatten의 정확한 형태 확정 — in-place 뮤테이션(클론 안 함), ProcessedModifier로 소진, 인라인 우선이 `~= nil` 하나로 성립. 단 주신 코드의 반복 방향은 역순이어야 "나중 modifier가 우선"이 성립해서 그것만 정정(F-4-2). - C-4: destroySlotTree의 명시적 releaseOwner 제거. 파괴된 걸 재사용하는 코드는 그 자체로 버그이므로 "비결정적으로 실패"보다 "항상 실패"가 낫다. - C-6: recompute의 sourceList[i] == nil을 skip에서 즉시 error로 승격. 재추적 결과 도달 경로가 없으므로 관측되면 부기가 깨진 것. - C-7: "부기가 물리 트리 조작보다 항상 먼저"를 일반 계약으로 승격. 빼기는 물리 먼저/넣기는 부기 먼저가 같은 원칙(좁은 쪽이 먼저)의 두 얼굴이라는 것과, yield 금지 덕에 프레임 경계가 안 끼므로 진짜 근거는 "백엔드가 전제할 수 있게 하나로 고정"이라는 것까지. followup F절에 남은 것: KeyGone 홀드 + Owned 설치 플래그 설계 제안(F-3), 단일 일반화 for 전환 여부/flatten 반복 방향/setLength 위치(F-4). Co-authored-by: qwreey --- .claude/base/brand-plan.md | 24 +- .claude/base/dispatch-core-plan.md | 85 ++++- .claude/base/modifier-plan.md | 64 ++++ .claude/base/slot-plan.md | 77 ++-- .../pre-implementation-qa-round4-followup.md | 338 +++++++++++++++++- .claude/todos.md | 12 +- 6 files changed, 563 insertions(+), 37 deletions(-) diff --git a/.claude/base/brand-plan.md b/.claude/base/brand-plan.md index 5cf50d7..42db944 100644 --- a/.claude/base/brand-plan.md +++ b/.claude/base/brand-plan.md @@ -22,6 +22,12 @@ Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서 통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 (`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). +**존재 이유 한 줄(2026-08-20 구현 전 QA 4라운드 `B-4`, 사용자 정리)**: +**`Brand`는 데이터 타입에 부작용을 남기지 않고 런타임 명시 타이핑을 하기 +위한 것이다.** 값 자체엔 아무것도 안 심고(외부 weak 레지스트리), 판별이 +읽기 부작용도 안 만든다 — 아래 duck-typing 기각 근거 두 개가 정확히 이 +한 줄에서 나온다. + **구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 사용(문자열 아님).** @@ -146,11 +152,19 @@ None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되 것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다. **duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 -이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의 -값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를 -가진 `T`에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키 -인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한 -엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. +이유 — 서로 독립된 두 가지(2026-08-20 `B-4`에서 분리 명시)**: + +1. **정확성: false positive.** `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 + 수 있는 **임의의 사용자 값**이다. 사용자가 우연히 `Compute`라는 필드를 가진 + 테이블을 넣으면 quad가 그걸 `State`로 오인한다. 브랜드는 quad가 만든 값에만 + 찍히므로 이 오인이 원천적으로 없다. +2. **안전성/비용: 인덱싱 자체가 터질 수 있음.** 일부 Roblox userdata는 **정의 + 안 된 키를 인덱싱하는 것만으로 에러를 던진다** — duck-typing을 하려면 판별 + 코드를 전부 `pcall`로 감싸야 하고, 그건 "판별은 부작용 없이 빠르게"라는 + `isHandlable` 계약(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)과 + 정면으로 부딪힌다. 최악의 경우 엔진이 죽는 상황까지 있다. + +weak-key 레지스트리 조회는 포인터 해싱 한 번이라 `pcall`도, 오인도 없다. weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이 GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index dafb5d0..276eb86 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -111,6 +111,19 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 > - **주의 — "3 → 2 → 1"은 한 `(inst,k)` 체인 안에서의 이야기다.** > 서로 다른 키(`(inst,k1)` vs `(inst,k2)`)는 완전히 별개 배열이고 > 서로의 순서와 무관하다(아래 "인덱스의 의미" 항목). + > - **⭐ [2026-08-20 `B-1`] "스택을 역순으로 푼다"는 `retractFrom`에만 + > 해당한다 — (A) 분기는 스택을 푸는 게 아니라 그 자리를 *교체*하는 + > 것이다.** 사용자 정리: *"같은 핸들러의 process로 retract 가 교체되는건, + > 말 그대로 교체라 stack down 이 아니고 retractFrom 는 stack down 을 + > 수행한다."* 둘을 같은 말로 묶어 읽으면 안 된다: + > - **(A) 분기(같은 핸들러 재프로세스)** — `slot.retractor(v)` 하나만 + > 불리고 **아래(index+1 이하)는 전혀 안 건드린다.** 그 자리 하나가 + > 새 클로저로 갈아끼워질 뿐이라 순서 개념 자체가 없다. + > - **`retractFrom`(단순 철거)** — 그때만 꼬리부터 목표 인덱스까지 + > **스택을 역순으로 푼다.** + > 위 "자기 아래는 이미 정리된 뒤"라는 보장도 **`retractFrom` 경로의 + > 이야기**다 — (A) 분기에서 클로저가 불릴 땐 아래가 그대로 살아 있고, + > 그게 바로 "깜빡임 없이 갈아끼우기"가 성립하는 이유다. 디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 @@ -971,6 +984,28 @@ end `NoneHandler`는 항상 재위임하므로 지금 위반 사례는 없지만, "조건부로만 재위임하는" 핸들러를 새로 만들면 재위임을 건너뛰는 그 자리에서 `Dispatch.retractFrom(inst, k, index + 1)`을 직접 불러 아래를 정리해야 함. + + **[예시 추가, 2026-08-20 구현 전 QA 4라운드 `B-3`]** 규칙만으론 뭐가 + 위험한지 안 드러난다는 지적을 받아 가상의 위반 사례를 같이 적어둠: + + ```lua + -- ⚠️ 이런 핸들러를 새로 만들면 고아 체인이 생긴다 + function MaybeWrapHandler.process(inst, k, v, index) + if v.enabled then + Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함 + end + -- v.enabled가 false면 아무것도 안 함 ← 여기가 문제 + return function() end + end + ``` + + - 1차 사이클 `v.enabled == true` → index+1에 하위 체인이 설치됨. + - 2차 사이클에 **같은 핸들러**로 `v.enabled == false`가 오면 (A) 분기다 — + (A)는 정의상 **아래를 안 건드리므로** `retractFrom`이 안 불린다. + - 그런데 이번엔 재위임을 안 했으니 **index+1의 옛 하위 체인이 그대로 + 남는다.** 아무도 안 지우고, 옛 값에 대한 구독/부작용이 계속 산다. + - **해법**: `else` 자리에서 + `Dispatch.retractFrom(inst, k, index + 1)`을 직접 부른다. - **`HandlerChanged` 같은 마커 값은 두지 않음** — "핸들러가 바뀜"은 **그 자리 retractor가 `nil`로 불린다는 사실 자체**로 이미 표현됨. 별도 마커를 만들면 그것도 결국 "인자로 넘어오는 정체불명의 값"이 되어 옛 모델의 @@ -1424,11 +1459,15 @@ local function recompute(ownerKey, bk) local offset = bk.sourceList[i] -- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라 -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함. - -- [방어, 2026-08-13 여섯 번째 세션] `nil`도 같이 배제 — 정상 - -- 상태에선 항상 None으로 채워지는 게 계약이지만(위 "None을 쓰는 - -- 이유"), 해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도 - -- 크래시 대신 skip이어야 함. 등록 쪽의 "반드시 None" 의무는 그대로. - if offset ~= nil and offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set + -- [전면 정정, 2026-08-20 QA 4라운드 `C-6`] `nil`은 skip이 아니라 error. + -- 도달 경로가 없다는 게 재추적 결론이므로(bk.N=실제 개수, 배치 중엔 + -- Blocker 게이팅, 해제는 None, spliceArraysDown은 압축), nil이 보이면 + -- 부기가 깨진 것 — 조용히 건너뛰면 위치 하나가 순서 계산에서 빠지는 + -- 추적 어려운 오작동이 된다. 상세는 base/slot-plan.md의 "추가 방어 조치". + if offset == nil then + error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)") + end + if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set offset:Set(sum) end local v = bk.lengthList[i] @@ -1610,6 +1649,42 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체 아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은 톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것. +### ⭐ 일반 계약 — 부기가 물리 트리 조작보다 항상 먼저 끝난다 (2026-08-20 구현 전 QA 4라운드 `C-7` 승격) + +**지금까지 이 규칙은 `rawAdd` 한 곳에만 적혀 있었다**(바로 아래 "동기 순서" +문단). `rawRemove`/`rawUnmount`는 의사코드가 우연히 같은 모양이었을 뿐 +계약화돼 있지 않았고, `Splice`는 물리 detach/attach와 부기의 선후가 **아예 +안 적혀 있었다.** 사용자 판정으로 **모든 `raw*`가 따르는 일반 계약으로 +승격**한다(*"각각의 동작에 따라 다른 동작 보다, 일관성 있는 동작을 제공하는게 +나아보이고, 이것을 단순 일반 계약 승격으로 도달 될 수 있기 때문"*). + +> **계약**: 어떤 CRUD/재조정 연산이든 **`Length`/`offset` 부기 갱신이 물리 +> 트리 조작(`Parent` 대입/해제)보다 먼저 완료**돼야 한다. 즉 "**먼저 밀어내고 +> 그 공간에 넣는다**" / "**먼저 비운 걸 반영하고 그 다음 당긴다**". + +- **왜 일반 계약이어야 하나**: 백엔드가 "밀어내기"를 물리적으로 구현해야 + 하는 경우(DOM `insertBefore` 밖의 백엔드 등), **부기가 이미 정확하다**는 걸 + 전제할 수 있어야 자기 일을 할 수 있다. 연산마다 선후가 다르면 백엔드 + 작성자가 매번 다시 확인해야 한다. +- **각 연산에 적용하면**: + - `rawAdd` — `Length:Set(newCount)`(→ 뒤 형제 offset 갱신 동기 완료) → + `element.Parent = target`. 이미 이렇게 확정돼 있음(아래 문단). + - `rawRemove`/`rawUnmount` — 파괴/언마운트 → `spliceArraysDown` → + `recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과 + 어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직 + 트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는 + 물리 먼저, 넣기는 부기 먼저"**가 실제로는 같은 원칙(항상 **좁은 쪽이 + 먼저**)의 두 얼굴이다. 문서화 시 이 대칭으로 적을 것. + - `Splice` — 제거 구간과 삽입 구간이 겹치므로 위 두 규칙을 그대로 이어 + 붙이면 된다(제거는 물리 먼저, 삽입은 부기 먼저). **shift/recompute를 + 1회로 묶는다는 최적화는 그 사이에서만** 일어난다. + - `rawMove`/`rawSwap` — `Parent`를 안 건드리므로 이 계약의 대상이 아님. +- **⚠️ 프레임 경계는 어차피 안 낀다** — `process`/`attachSlot` 체인 도중 + 코루틴 yield가 금지돼 있으므로(위 "Handler 작성 체크리스트" 9번) 이 + 순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이 + 계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로 + 고정한다"**가 진짜 이유다. + **동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index df878ae..493e304 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -39,6 +39,70 @@ cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드 아닌 값은 flatten을 그냥 통과함 — `component-composition-plan.md`의 "필수 관용구" 절 참고). +### flatten의 정확한 형태 — in-place 뮤테이션 + `ProcessedModifier` 소진 (2026-08-20 구현 전 QA 4라운드 `M-2` 확정) + +**여기 있던 갭**: 위 문단은 "Modifier 항목의 필드를 뽑아 merge한다"고만 적고 +**뽑아낸 그 배열 자리를 어떻게 하는지를 한 번도 안 적었다.** 그냥 지우면 +배열에 구멍이 생기고, 그건 `PreRef` pre-pass가 `ProcessedPreRef`로 소진해야 +했던 바로 그 이유(구멍 하나로 테이블 전체가 해시 취급이 되어 배열 파트 +순서 보장을 잃음, `base/ref-plan.md`)에 정확히 걸린다. + +**확정(사용자 판정)**: `Pre`/`PostRef`와 **완전히 같은 방식** — 전용 센티널 +`ProcessedModifier`로 그 자리를 소진하고, 전담 nop Handler +`ProcessedModifierHandler`가 정상 `Dispatch.process` 경로에서 캐치해 +`setOffsetSource(None)`/`setLength(0)`을 등록한다. **새 규칙이 하나도 안 +늘어난다.** + +**그리고 flatten은 새 테이블을 만들지 않는다 — 입력을 제자리에서 뮤테이션한다** +(사용자 판정: *"flatten은 클론을 할 필요도 없음. derive 에 들어갈 것 자체가 +그 자체로 소비되는 테이블이라 그 이후 사용하는 경우가 존재하지 않고, 새 +테이블을 만드는 비용 자체를 지불할 필요가 없음"*). props 테이블 리터럴은 +그 호출 한 번을 위해 만들어져 소비되고 끝이므로 원본 보존 의무가 없고, +`Pre`/`PostRef` 소진이 이미 `flattened`를 제자리에서 갈아치우는 것과 같은 +취급이다. + +```lua +-- 사용자 제시 형태(반복 방향만 아래 ⚠️대로 정정) +function flatten(input) + for i = #input, 1, -1 do -- ⚠️ 역순 — 아래 참고 + local value = input[i] + if not isModifier(value) then + continue + end + input[i] = ProcessedModifier -- 구멍 대신 센티널로 소진 + + for key, modValue in value... do + if input[key] ~= nil then + -- 이미 누가 차지한 자리는 건드리지 않음. + -- 언셋은 `None`(실재값)이므로 `~= nil` 확인 하나로 충분 — + -- 인라인 `None`도 "이미 차지함"으로 잡혀 modifier를 이김. + continue + end + input[key] = modValue + end + end + return input +end +``` + +- **왜 `~= nil` 하나로 인라인 우선이 성립하나**: 인라인 해시 키는 flatten이 + 돌기 **전에 이미 테이블에 들어 있다**. 그래서 "이미 값이 있으면 건너뛴다"가 + 곧 2번 절의 "인라인 키는 modifier보다 무조건 우선" 규칙이 된다 — 별도 + 분기가 필요 없음. 명시적 unset(`None`)도 실재값이라 같은 검사에 잡힌다. +- **⚠️ 반복은 반드시 역순(`#input` → `1`)이어야 한다.** "이미 있으면 + 건너뛴다"는 **먼저 쓴 쪽이 이기는** 규칙이라, 정방향으로 돌면 배열 + **앞쪽** modifier가 이겨 2번 절의 "배열 순서상 **나중** modifier가 우선"과 + 정반대가 된다. 역순으로 돌면 마지막 modifier가 먼저 써서 이긴다 — + 인라인 우선은 어느 방향이든 그대로 성립(인라인은 루프 시작 전에 이미 + 들어 있으므로). +- **배열 파트를 훑으며 해시 키를 같이 쓰는 것은 안전** — 숫자 `for`의 상한이 + 루프 진입 시 한 번만 평가되고, 해시 파트 추가는 그 순회에 영향을 주지 + 않는다. +- **`ProcessedModifier`의 공개 표면 위치**는 `Detach`/`None`과 같은 판단을 + 따른다 — 다만 이건 사용자가 볼 일이 전혀 없는 **내부 센티널**이라 + 최상위로 재노출할 이유가 없어 보인다(`ProcessedPreRef`/`ProcessedPostRef`가 + 그렇듯). 구현 시 그 둘과 같은 자리에 둘 것. + 관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시 해야 하는지/clone이 필요한지는 별개 문제였는데, **[2026-08-14 세션] 그 재바인드 기능 자체가 기각**되어 질문이 없어짐 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 41eaa34..ce18097 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -471,17 +471,36 @@ releaseOwner(element, slot) 클레임 미접촉) 무조건 error가 맞음 — top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함. -**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨.** `elementOwner`는 -값도 `SetWeak`이라 "파괴된 Slot이 아무에게도 참조되지 않게 되면 소유권 -기록도 저절로 사라진다"가 원리적으로는 맞지만, **그게 언제인지가 GC -타이밍에 달려 있어서** 그 전에 같은 element를 다른 곳에 넣으려 하면 -"이미 마운트돼 있음" error가 비결정적으로 터짐(사용자가 직접 들고 있는 -nested `Slot`을 파괴 후 재사용하는 경로가 정확히 이 케이스). 그래서 -`rawRemove`/`rawExtract`뿐 아니라 **`destroySlotTree`도 자기 자식들의 -`releaseOwner`를 명시적으로 부름** — 원래 이 함수는 소유권을 전혀 안 -건드리고 있었음. top-level Slot 자신의 반납은 `SlotHandler.process`가 +**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawRemove`/ +`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다. `elementOwner`는 +값도 `SetWeak`이라 "아무에게도 참조되지 않게 되면 소유권 기록도 저절로 +사라진다"가 원리적으로는 맞지만, **그게 언제인지가 GC 타이밍에 달려 있어서** +그 전에 같은 element를 다른 곳에 넣으려 하면 "이미 마운트돼 있음" error가 +비결정적으로 터짐. top-level Slot 자신의 반납은 `SlotHandler.process`가 반환한 클로저가 담당(층위 분리는 `unbindLifetime`과 동일한 원칙). +**[재정정, 2026-08-20 구현 전 QA 4라운드 `C-4`] 단, `destroySlotTree`는 이 +규칙의 대상이 아니다 — 명시적 `releaseOwner`를 도로 뺀다.** 2026-08-13 +감사가 같은 근거로 `destroySlotTree`에도 넣었었는데, 사용자 판정으로 +되돌림: *"Destroy 된 요소는 다른곳에 원래 마운트 못하는게 보통 엔진 +정상이고, 또, 릴리즈 안 되어 다른곳에 마운트 막혀도 상관 없고, 그게 정상 +동작일 수 있어보임."* + +- **막히는 게 정상이다** — 파괴된 요소를 다른 곳에 다시 넣으려는 코드는 그 + 자체로 버그다. "비결정적으로 실패"의 반대는 "성공"이 아니라 **"항상 + 실패"**이고, 그게 더 나은 동작이다. 명시적 반납은 오히려 그 버그를 + 통과시켜 죽은 Instance를 엉뚱한 데서 터지게 만든다. +- **`rawRemove`/`rawExtract`와 갈리는 이유**: 그쪽은 요소를 **살려서** + 호출자에게 돌려주는 경로라 "이제 다른 곳에 넣어도 된다"가 정상 시나리오다. + `destroySlotTree`는 그 반대 — 요소가 죽는다. +- **남는 성질**: 반납을 안 하므로 GC 전엔 error, GC 후엔 통과라 **여전히 + 비결정적**이다. 그래도 두 결과 모두 "버그 있는 코드"에 대한 것이라 + 실사용 위험이 없고, 결정적으로 만들려면 "파괴됨" 마킹이라는 새 부기가 + 필요해 `conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/ + 최적화하려고 구조를 복잡하게 만들지 않는다" 원칙에 어긋난다. 파괴된 값의 + 재사용이 UB라는 건 `Ref`("Destroy와는 무관")에서 이미 확립된 관례이기도 + 하다. + `ownerKey`가 `inst`(top-level)든 `Slot`(nested)든 `elementOwner`는 타입을 신경 안 써서 하나의 레지스트리로 충분 — `outerSlot`이 값으로 들어가도 `elementOwner` 자체는 아무것도 강하게 안 붙잡고(전부 @@ -1702,9 +1721,9 @@ end local function destroySlotTree(slot) for i, element in ipairs(slot._elements) do - -- [정정, 2026-08-13 감사] 소유권 반납을 먼저 — 아래 "소유권 반납은 - -- GC에 맡기면 안 됨" 참고 - releaseOwner(element, slot) + -- [재정정, 2026-08-20 구현 전 QA 4라운드 `C-4`] 여기서 releaseOwner를 + -- 명시적으로 부르지 **않는다** — 2026-08-13 감사가 넣었던 것을 되돌림. + -- 근거는 아래 "소유권 반납은 GC에 맡기면 안 됨" 절의 재정정 참고. if isSlot(element) then destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음 else @@ -1989,12 +2008,15 @@ return Slot { 중첩한 경우에도 정확히 같은 규칙 하나로 동작함(래퍼 sugar는 그저 그 일반 메커니즘의 사용자일 뿐). -**단 이 결론은 위 "요소 소유권" 절의 2026-08-13 감사 수정 셋을 전제함** — +**단 이 결론은 위 "요소 소유권" 절의 2026-08-13 감사 수정 **둘**을 전제함** — (1) nested `claimOwner`가 엄격(같은 owner 재클레임도 error)이라 `Slot { a, a }`가 실제로 막히고, (2) `rawRemove`/`rawUnmount`가 -`releaseOwner`를 실제로 부르고(의사코드에서 빠져 있었음), (3) `destroySlotTree`가 자식 -소유권을 GC에 안 맡기고 명시적으로 반납. 셋 중 하나라도 빠지면 이 표의 -중간 단계가 어긋남. +`releaseOwner`를 실제로 부름(의사코드에서 빠져 있었음). 둘 중 하나라도 +빠지면 이 표의 중간 단계가 어긋남. +**[정정, 2026-08-20 `C-4`]** 원래 여기 셋째로 "(3) `destroySlotTree`가 자식 +소유권을 명시적으로 반납"이 있었으나 그 수정 자체가 되돌려졌다(같은 절의 +재정정 참고) — **이 표와는 무관**하다. 이 표가 다루는 건 `reconcile`의 +`rawUnmount`→`rawAdd` 왕복이고 파괴 경로가 아니기 때문. ### [전면 정정, 2026-08-13 여섯 번째 세션 후속, 사용자 결정] `State` 교체는 **파괴가 아니라 언마운트** — `state`와 완전히 동일 @@ -2238,11 +2260,24 @@ state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는 - **따라서 `Slot.Offset`은 "마운트 전엔 `nil`"이 아니다** — 아래 "`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드" 관련 서술과 `unmountSlotTree` 의사코드도 이 정정에 맞춰 갱신됨(같은 라운드). -- **`recompute`는 `sourceList[i]`가 `None`이든 `nil`이든 "참여 안 함"으로 - 똑같이 관대하게 넘어갈 것** — 정상 상태에선 항상 `None`으로 채워지는 게 - 계약이지만(`nil`은 배열에 구멍을 냄), 해제/재마운트가 얽히는 전이 - 구간에서 `nil`이 관측돼도 크래시 대신 skip이어야 함. 이건 계약 완화가 - 아니라 **순수 방어** — 등록 쪽은 여전히 `None`을 쓸 의무가 있음. +- **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `C-6`] `recompute`가 + `sourceList[i] == nil`을 관대하게 skip하지 않는다 — 즉시 `error`다.** + 옛 서술은 "해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도 크래시 + 대신 skip"이었는데, 사용자 의문(*"애초에 해제에서 nil이 관측 될 일이 + 없다고 생각하는데"*)을 받아 다시 추적한 결과 **지금 설계에서 도달 + 경로를 찾지 못했다**: + - `bk.N`이 "그때그때 실제 개수"로 확정돼(2026-08-18) `lengthList`/ + `sourceList`가 아직 안 채워진 위치를 `recompute`가 읽을 일 자체가 없음. + - 배치 등록 중엔 Blocker 게이팅으로 `recompute`가 아예 안 돎. + - 해제는 `setOffsetSource(None)` → `setLength(0)`이라 `None`이지 `nil`이 + 아님. + - `spliceArraysDown`은 배열을 **압축**하므로 중간에 구멍을 안 남김. + + 그러면 `nil`이 관측된다는 건 **부기가 깨졌다는 신호**이고, 관대한 skip은 + "위치 하나가 조용히 순서 계산에서 빠지는" 추적 어려운 오작동이 된다 — + `Dispatch`의 "매치 실패는 조용한 무시 없이 즉시 error"/`releaseOwner` + 불일치 error와 같은 톤으로 **즉시 error**가 맞다(사용자 동의). + 등록 쪽이 `None`을 쓸 의무는 그대로. **`state>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된 것"으로 수용**(사용자 판단) — `state>`와 같은 범주로, diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md index 819173e..d1b6f94 100644 --- a/.claude/qa-request/pre-implementation-qa-round4-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -1,6 +1,11 @@ # 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문 -**상태**: **[2026-08-20] 1차 처리 완료, 아래 B/C절 회신 대기.** +**상태**: **[2026-08-21] 2차 처리 완료 — 아래 F절이 최신.** +B절은 전부 확인됐고 C절 결정도 대부분 반영됐다. **지금 열려 있는 건 F절의 +`F-3`(`KeyGone`/`Owned` 설계 제안에 대한 확인)과 `F-4`(새로 발견한 불일치 +2건 + `setLength` 위치 재질문)뿐이다.** A~E절은 그 처리 과정의 기록. + +**[2026-08-20] 1차 처리 — 아래 A~E절.** **입력**: `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 그 파일이 소스이고 여기서 전문을 반복하지 않음). 이 문서는 그 회신을 (a) 바로 @@ -657,3 +662,334 @@ selene/Rojo/darklua 경계)와 `base/quad-types-plan.md`(`AddPlugin`/ 주면 된다. **`C-1`(KeyGone)과 `C-2`(dispose vs `state` 충돌)가 가장 파급이 크고 나머지를 막고 있다.** - **D절** — 5라운드를 만들지만 알려주면 된다. + +--- + +# F. 2차 회신 처리 (2026-08-21) + +**상태**: **B절 전부 확인 완료, C절 결정 대부분 반영 완료.** 남은 건 아래 +`F-3`(내 답변에 대한 확인)과 `F-4`(새로 발견한 불일치 2건 + 재질문 1건)뿐이다. + +## F-1. B절 — 전부 확인됨, 확인 과정에서 나온 보강만 + +| 항목 | 결과 | 보강해서 반영한 것 | +|---|---|---| +| `B-1` | 확인 | **"(A) 분기는 교체이지 stack-down이 아니다"**를 명시 — `retractFrom`만 스택을 역순으로 풀고, (A)는 그 자리 하나를 갈아끼울 뿐 아래를 안 건드린다. "자기 아래는 이미 정리된 뒤"라는 보장도 `retractFrom` 경로 한정이고, (A)에서 아래가 살아 있는 게 바로 깜빡임 없는 갈아끼우기가 성립하는 이유. | +| `B-2` | 확인(조건부) | **⚠️ 조건이 지금 안 맞는다 — `F-4-1` 참고.** | +| `B-3` | 확인 | 가상 위반 예시(`MaybeWrapHandler`)를 base 문서에 그대로 넣음. | +| `B-4` | 확인 | *"Brand는 데이터 타입에 부작용을 남기지 않고 런타임 명시 타이핑을 하기 위한 것"* 이라는 존재 이유 한 줄을 `brand-plan.md` 머리에 추가하고, duck-typing 기각 근거를 **정확성(false positive) / 안전성·비용(인덱싱이 터짐)** 둘로 분리해 적음. | +| `B-5` | 확인 | 변경 없음. | +| `B-6` | — | 새 질문이라 아래 `F-2`에서 답함. | +| `B-7` | 확인 | 변경 없음(base의 "(b) owner" 표현은 아직 안 고침 — `F-4-3`). | +| `B-8` | `C-6`로 흡수 | `error` 승격 반영 완료. | +| `B-9` | 확인 | `C-5`/`C-7` 결정이 들어가면서 "먼저 length가 밀린다"의 적용 범위가 일반 계약으로 올라감(`C-7` 반영분). | +| `B-10` | 확인 | *"우리에게 있어 렌더는 derive 되었는 결과일 뿐"* — 이 프레이밍이 `OnRendered` 이름 유지 근거를 더 정확하게 만든다. 문서화 시 쓸 문장으로 기록. | + +## F-2. `B-6` 답변 — 재귀적 Clear에 Blocker를 재귀적으로 쓰면 되지 않나 + +**질문**: *"재귀적 clear 시 blocker를 다시 재귀적 on 하고, 재귀가 풀려 돌아올 +때 off 해나가도 되는것으로 보이는데, 아닌가?"* + +**답: 동작은 하지만, 문제의 절반만 없앤다.** 두 비용이 있는데 Blocker는 하나만 +잡는다. + +| 비용 | 재귀적 `Clear()` | Blocker로 가려지나 | +|---|---|---| +| `recompute` 전체 순회가 요소마다 1회 | 500개면 500회 × 500칸 | **가려짐** — `blocker:IsOn()`이면 스킵 | +| `spliceArraysDown`이 요소마다 배열을 한 칸씩 당김 | 500회 × 평균 250칸 이동 | **안 가려짐** — Blocker와 무관한 순수 배열 조작 | + +- **그리고 배치 게이팅엔 "끝에 한 번"이 있는데 파괴엔 그게 없다.** Blocker + 패턴은 `On` → 등록 → `OffWithoutEmit` → **마지막에 recompute 1회**가 한 + 세트인데, 죽는 서브트리에선 그 마지막 recompute조차 의미가 없다(결과를 읽을 + 주체가 없음). 즉 Blocker를 쓰면 "안 쓸 계산을 미뤘다가 안 쓰고 버리는" 모양이 + 된다. +- **근본적으로는, 죽는 서브트리의 부기는 유지할 이유 자체가 없다.** + `destroySlotTree`가 `spliceArraysDown`도 `recompute`도 아예 안 부르는 게 + "가리는" 것보다 싸고 단순하다. 바깥에서 딱 한 번(그 Slot이 차지하던 position에 + 대해 `setOffsetSource(None)` → `setLength(0)`) 도는 걸로 충분. +- **다만 "중첩마다 Blocker를 새로 만들어 재귀 On/Off"라는 패턴 자체는 정당하다** — + `attachSlot`의 flush가 이미 정확히 그렇게 하고 있고(`base/blocker-plan.md`의 + "재진입" 절이 요구하는 대로 부모 것을 재사용하지 않음), 파괴에만 안 쓰는 것. + +→ **이 판단이 맞나?** (맞으면 base의 "재귀적 `Clear()` 금지" 절에 "Blocker로 +가려도 shift 비용은 남는다"는 이유를 한 줄 추가하겠다.) + +## F-3. ⭐ `C-1`/`C-2` — 물어보신 것에 대한 답 + 구체 설계 제안 + +**질문**: *"KeyGone 이여도 여전히 ud 로 홀드 가능하다 … 대신에 slot 의 소유주가 +죽으면 같이 죽는다. ud 도 모두 정리된다. 이 점에 대해서 어떻게 생각하는가?"* + +**동의한다. 그리고 그건 "있으면 좋은 것"이 아니라 없으면 안 되는 것이다** — +아래 (1)이 그 이유다. + +### (1) 지적하신 누수는 "GC가 언젠가 치운다"가 아니라 **영구 누수**다 + +`Detach`된 요소는 `Parent = nil`인 quad-제작 Instance인데, quad는 **자기가 만든 +Instance마다 gcconn을 걸고 그 클로저가 `inst`를 캡처**한다 +(`base/lifecycle-pattern.md`의 "(0)" 절). 그 문서가 이미 대가로 못박아둔 게 +정확히 이것 — **"quad가 만든 Instance는 참조를 놓는 것만으로는 회수되지 않고 +반드시 `Destroy`로 회수된다."** + +즉 detached 노드는 아무도 안 들고 있어도 **자기 자신의 시그널 커넥션이 자기를 +살려서** 영원히 남는다. "부모가 Destroy돼도 안 죽는다"는 지적이 정확할 뿐 +아니라, **GC 폴백조차 없다.** 그래서 명시적 정리 경로가 **필수**다. + +### (2) 제안 — detached 요소는 `userdata`가 아니라 **Slot의 필드**가 들고 있어야 한다 + +`ud`로 홀드하는 것도 물론 가능하지만(사용자가 원하면), **`:List` 자신도 별도로 +들고 있어야** 한다. 이유 셋: + +1. **`userdata`는 `:List`에게 opaque하다** — 계약상 "안을 전혀 안 들여다본다" + 이므로, 정리 시점에 **뭘 죽여야 하는지 알 수가 없다.** +2. **소유권이 Slot에 남아야 한다** — `elementOwner`가 여전히 이 Slot을 가리켜야 + detached 요소를 다른 곳에 못 붙인다(안 그러면 "떼어놨는데 남이 가져감"). +3. **파괴 walk가 닿아야 한다** — `destroySlotTree`는 `_elements`만 훑는데 + detached는 거기 없다. **이게 마지막에 물어보신 "+" 항목(`dispose`가 + 재귀적으로 잘 죽이는가)의 핵심**이다 — 아래 (5). + +그래서 `slot._detached[key] = element` 같은 **Slot 필드**를 제안한다(클로저 +업밸류가 아니라 필드여야 파괴 경로가 닿음). + +**부수 이득 — `ud`로 홀드할 필요가 없어진다.** `:List`가 들고 있으므로 다음 +사이클에 그냥 **`prev`로 다시 넘겨주면 된다.** `updateFn`은 `prev`를 그대로 +반환하는 것만으로 재마운트되고, "detach된 prev"와 "마운트된 prev"를 구분할 +필요도 없다(재마운트가 필요한지는 `:List`가 안다). 사용자가 `ud`에도 넣고 +싶으면 그건 그냥 자유. + +### (3) owner 죽음 처리 — `Effect` 사용에 동의, 단 **소유 층위가 `attachSlot`** + +제안하신 대로 `Effect`가 맞다. `bindLifetime`은 "실행해도 되는가"만 게이팅할 뿐 +**죽는 순간의 콜백을 안 주므로**, 실제 파괴를 하려면 cleanup 계약을 가진 +`Effect`가 유일한 도구다(`LP-2`에서 확정한 *"당장은 Effect 뿐임"* 과도 일치). + +```lua +-- attachSlot 안(개념 스케치) +local handle = Effect(function() + return function() -- physicalTarget이 죽을 때 정확히 1회 + for key, element in pairs(slot._detached) do + if isSlot(element) then destroySlotTree(element) else element:Destroy() end + end + slot._detached = {} + end +end) +bindLifetime(physicalTarget, handle) +``` + +**⚠️ 단, `activateList`가 아니라 `attachSlot`/`unmountSlotTree` 쌍이 소유해야 +한다.** `activateList`는 마운트당 한 번이지만, Slot은 **언마운트 후 다른 +physicalTarget에 재마운트**될 수 있다(포탈, 이미 확정된 동작). Effect가 옛 +target에 묶인 채로 남으면 **그 옛 target이 죽을 때 지금 살아있는 Slot의 +detached 요소를 파괴**한다. 그래서: + +- `attachSlot` — Effect 생성 + `bindLifetime(physicalTarget, handle)` +- `unmountSlotTree` — `unbindLifetime(handle)`(다른 observer들 푸는 자리와 같은 줄) + +### (4) `KeyGone` 후 "다시 안 묻기"는 자동으로 성립한다 — 새 규칙 불필요 + +`C-1`에서 제가 걱정했던 "홀드하면 매 사이클 다시 물어보게 되나"는 **지금 +구조에서 저절로 풀린다**: + +- 소멸 루프는 **직전 사이클의 `keyIndex`**(= 그때 데이터에 있던 키)만 순회한다. +- 데이터에서 사라진 키는 이번 사이클 `keyIndex`에 안 들어가므로 **다음 + 사이클엔 소멸 루프 대상이 아니다** → 재질문 없음. +- 홀드된 것은 `_detached`/`userdata`에 조용히 남아 있다가: + - **키가 데이터에 다시 나타나면** `prev`로 부활(정확히 filter 재등장 시나리오), + - **owner가 죽으면** (3)의 Effect가 정리. + +즉 `C-1`의 미결 4개 중 **2·3번(userdata 수명, 소멸 루프 순회 대상)이 이걸로 +닫힌다.** 남는 건: + +- **`updateFn`이 `KeyGone`을 받았을 때 `prev`를 그대로 반환하면?** — 키가 없는데 + 계속 마운트해두라는 뜻이라 모순이다. **`error`가 맞다고 본다**(다른 CRUD + 에러 조건들과 같은 fail-fast 톤). 확인 부탁. +- **`index`/`offset` 인자** — 사라진 키엔 위치가 없다. `updateFn` 시그니처가 + `index: number`로 확정돼 있어 `nil`을 넣으면 타입이 바뀐다. **`0`을 넘기는 + 것**을 제안한다 — `offset`/`sum`이 이미 0-based 개수라 "아무 자리도 차지하지 + 않음"이 0으로 자연스럽게 표현되고, 타입도 안 바뀐다. `offset`은 그냥 Slot의 + 것을 그대로(항상 유효). + +### (5) `C-2`(unowned replace)는 **`Detach`와 섞지 말고 설치 단위 플래그**로 + +*"Detach 에서 replace 가 있냐 없냐고 Detach 를 지울지 말지 결정해야한다. +따라서, 차라리 Detach 이외의 무언가가 필요하다"* — **정확한 진단이고, 그래서 +반환값 계열에 하나를 더 만드는 것보다 축을 아예 분리하는 게 맞다.** 두 개념이 +직교하기 때문이다: + +| | `Detach` | unowned | +|---|---|---| +| 뜻 | "지금은 안 쓰지만 **내 것**" | "**애초에 내 것이 아님**" | +| owner 죽을 때 | **같이 죽는다** | 안 죽는다(사용자 것) | +| 언제 정해지나 | **사이클마다** 다름 | **설치 시점에 고정**(누가 만들었는가) | +| 소유권 | Slot이 유지 | Slot이 애초에 안 가짐 | + +**마지막 행이 결정적이다** — unowned는 per-cycle 판단이 아니라 **"이 `:List`가 +만드는 요소인가, 사용자가 넘긴 요소인가"** 라는 설치 단위 속성이다. 그래서 +반환값에 넣으면 매 사이클 같은 답을 반복하게 되고, `Detach`에 얹으면 지적하신 +대로 의미론이 분화한다. + +**제안**: `:List`/`:Single`에 옵션 하나. + +```lua +Slot:Single(state, updateFn?, opts?) -- opts.Owned: boolean? (기본 true) +Slot:List(data, updateFn, keyFn?, opts?) +``` + +- `Owned = true`(기본) — `:List`가 만든 것으로 간주. 교체/소멸 시 **파괴**, + `Detach`면 홀드했다가 owner 죽을 때 파괴. +- `Owned = false` — 사용자 소유. **어떤 경로로도 파괴하지 않고 언마운트만** + 한다(교체·`KeyGone`·owner 죽음 전부). `Slot:Add(state)` sugar가 이걸로 + 설치한다. +- **`destroySlotTree`/`dispose`도 이 플래그를 봐야 한다** — `Owned = false`인 + Slot을 파괴할 땐 자기 요소를 죽이지 않고 언마운트만. (아래 (6)과 직결) +- **수동 CRUD와 안 부딪힌다** — `Owned` 플래그는 `:List`/`:Single`을 설치할 + 때만 생기고, 그 Slot은 `_listed`라 수동 CRUD가 이미 막혀 있다. +- **혼합 케이스**(한 리스트에 내 것과 남의 것이 섞임)는 표현 못 하지만, 실사용 + 사례가 안 떠오르고 필요하면 그때 `Detach` + 수동 관리로 우회 가능하다. + +**이름**: `Owned`가 무난해 보인다. `Unowned = true`(부정 기본값)보다 읽기 쉽고, +`elementOwner`/`claimOwner`/`releaseOwner`라는 기존 어휘와 같은 뿌리다. +**더 나은 이름이 있으면 알려주면 그걸 쓰겠다.** + +### (6) 마지막 "+" 항목 — `dispose`가 slot-in-slot에서 재귀적으로 잘 죽이는가 + +**지금 상태 그대로면 "절반만" 죽인다.** 확인 결과: + +- ✅ **중첩 Slot 재귀는 이미 된다** — `destroySlotTree`가 `_elements`를 훑다 + `isSlot(element)`면 `destroySlotTree`로 재귀한다. +- ✅ **`dispose(slot)`도 그 walk를 그대로 탄다** — "아직 트리에 요구되고 있으면 + 거부, 아니면 재귀 파괴". +- ❌ **detached 요소는 안 죽는다** — `_detached`는 `_elements`에 없으므로 walk가 + 닿지 않는다. (2)에서 필드로 두자고 한 이유가 이것. +- ❌ **`:List`의 `mounted`/`userdata`/`keyIndex`는 `activateList`의 클로저 + 업밸류**라 파괴 walk가 손댈 수 없다. Slot이 통째로 unreachable해지면 클로저도 + 같이 죽으니 **부기 자체는 문제없지만**, 그 안에 사용자가 넣어둔 게 quad-제작 + Instance면 (1)의 이유로 안 죽는다. + +**그래서 필요한 것**(위 제안들의 귀결): + +1. `slot._detached`를 **필드로** 두고 `destroySlotTree`가 `_elements` 다음에 + 그것도 훑어 파괴. +2. `unmountSlotTree`는 **`_detached`를 안 건드린다**(언마운트는 파괴가 아니고, + 재마운트 시 그대로 이어져야 함). +3. `Owned = false`면 `destroySlotTree`도 파괴 대신 언마운트. +4. `userdata` 안의 것은 여전히 **사용자 책임** — 이미 확정된 "`userdata`엔 + GC만으로 정리되는 값만 담을 것" 제약(`SL-38`)에 **"quad가 만든 Instance는 + GC로 안 죽는다"를 명시적 예시로 추가**해야 한다. 지금 그 절은 + `:Subscribe()`한 Observer만 예로 들고 있어서 Instance는 안전해 보인다. + +→ **(1)~(6) 전체가 맞나?** 특히 (5)의 `Owned` 플래그 방향과 (2)의 "detached는 +Slot 필드"가 핵심이다. **여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/"+"가 한 번에 +닫히고, 그때 base 반영을 하겠다**(지금은 하나도 안 고쳤다). + +## F-4. 새로 발견한 불일치 2건 + 재질문 1건 + +### F-4-1. ⚠️ `B-2`의 조건이 지금 안 맞는다 — 두 패스가 정말 "단순 일반화 for"인가 + +**조건부 확인**: *"ipairs, pairs 를 따로 사용하게 되는게 아닌 단순 일반화 for +로써 얻어지는게 맞는 상태라면, 맞는 구현이다."* + +**확인해보니 지금은 그 조건이 안 맞는다.** M0 스파이크 +`luau-test/done/01-two-pass-array-hash-order.luau`는 **루프를 두 번 돈다**: + +```lua +-- pass 1: 숫자 for +local n = #flattened +for i = 1, n do ... end +-- pass 2: 일반화 for, 배열 인덱스(1..#t)는 건너뜀 +``` + +`ipairs`/`pairs`를 따로 쓰는 건 아니지만 **순회 자체가 2회**다. + +**단일 일반화 `for` 하나로 줄일 수 있는가 — 가능해 보인다**: + +- `flattened`는 **항상 Luau 테이블**이다. 백엔드가 뭐든 props는 사용자가 쓴 + Lua 테이블 리터럴에서 오므로, `B-2`에서 근거로 든 "다른 백엔드가 props를 Lua + 테이블이 아닌 자료구조로 표현할 수도"는 **`inst`에는 해당해도 `flattened`에는 + 해당하지 않는다** — 그 근거가 과했던 것 같다. +- 그러면 단일 일반화 `for k, v in flattened do`가 배열 → 해시 순서를 그대로 + 주고, `type(k) == "number"`로 두 층위를 가르면 된다. **순회 1회 절약.** +- 어차피 `PreRef`/`PostRef` pre-pass가 별도 순회 하나를 쓰므로, 전체는 + **2회(pre-pass + 본 루프)** vs 지금 **3회**가 된다. + +**남는 위험 하나**: 단일 일반화 `for`는 "배열 파트 전체가 해시 파트보다 먼저"를 +**Luau 테이블 구현에 의존**한다. `nil`-hole로 배열 파트가 쪼그라들면 일부 숫자 +키가 해시 파트로 밀려 순서가 섞이는데, 이건 `#flattened`를 쓰는 지금 방식도 +똑같이 깨지므로 **차이가 아니다**(둘 다 `02`/`06` 스파이크의 nil-hole 규율에 +의존). + +→ **판단 부탁**: (a) 단일 일반화 `for`로 바꾸고 스파이크 `01`도 그 형태로 +재작성, (b) 지금의 두 루프 유지. **저는 (a)를 추천**한다 — 순회가 하나 줄고, +"명시적 두 패스"의 진짜 근거(이식성)는 `flattened`엔 애초에 적용되지 않기 +때문이다. 다만 `01`이 이미 통과한 스파이크라 재작성 판단은 사용자 몫. + +### F-4-2. ⚠️ `C-3` 코드의 반복 방향 — 정방향이면 merge 우선순위가 뒤집힌다 + +주신 `flatten` 스케치를 그대로 반영하되 **반복 방향만 역순으로 고쳤다.** 이유: + +```lua +if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = 먼저 쓴 쪽이 이김 +``` + +- 정방향(`for i = 1, #input`)이면 배열 **앞쪽** modifier가 먼저 써서 이긴다. +- 그런데 확정된 규칙은 `modifier-plan.md` 2번의 **"배열 순서상 *나중* modifier가 + 우선"**이다. +- **역순(`for i = #input, 1, -1`)**으로 돌면 마지막 modifier가 먼저 써서 이기므로 + 규칙과 맞는다. +- **인라인 우선은 어느 방향이든 그대로 성립** — 인라인 해시 키는 루프가 돌기 + 전에 이미 테이블에 있으므로 항상 이긴다. (`None`도 실재값이라 같이 잡힘 — + 주신 "`nil` 확인으로 충분" 판단 그대로.) + +나머지(in-place 뮤테이션, 클론 안 함, `ProcessedModifier` 소진, 숫자 `for` +도중 해시 키 추가 안전)는 그대로 반영했다. + +→ **역순이 맞나?** (혹시 merge 우선순위 쪽을 "앞이 이김"으로 바꿀 생각이셨다면 +그게 더 큰 변경이라 따로 알려주면 좋겠다.) + +### F-4-3. `C-5` — `setLength` 위치, 두 해석이 갈린다 + +**"동의"를 받았는데 제 제안과 열거해주신 4단계가 서로 다른 자리를 가리켜서, +임의로 안 고치고 그대로 뒀다.** + +- **제 `C-5` 제안**: `Dispatch.setLength(ownerKey, position, slot.Length)`를 + **flush 루프 *뒤*(recompute 다음)**로 옮긴다 → 부모가 `Length = 0`으로 한 번 + 헛도는 걸 없앰. +- **열거해주신 4단계**: 1 `setOffsetSource` → 2 액티베이션 → **3 `setLength`** → + 4 실제 등록(마운트). 이 순서면 `setLength`가 flush **앞**이고, **그건 지금 + 코드와 같은 자리**다. + +**어느 쪽이든 트레이드오프가 하나씩 있다**: + +| | flush **앞**(현행/4단계) | flush **뒤**(제 제안) | +|---|---|---| +| 부모 recompute 횟수 | **2회** — 등록 즉시 1회(`Length`=0) + flush 끝나고 1회 | **1회** | +| `C-7` 일반 계약("부기 먼저") | **지킴** — 이 Slot의 기여가 자기 요소 마운트보다 먼저 반영 | **어김** — 요소가 붙은 뒤에 부모 부기가 갱신됨 | +| 값 정확성 | 결국 맞음(자기 교정) | 처음부터 맞음 | + +- **`Length`를 flush 앞에서 최종값으로 아는 건 불가능하다** — 중첩 Slot 요소의 + `.Length`는 그 요소의 `attachSlot`이 돌아야 정해지는데, 그게 flush 루프 + 안이다. 그래서 "3단계에서 확정된 길이로 setLength"는 **평범한 Instance + 요소만 있을 때만** 성립한다. +- **프레임 경계는 어느 쪽이든 안 낀다**(yield 금지) — 그래서 "어겨도 안 깜빡인다" + 이고, 순수하게 **일관성 vs 낭비 1회**의 선택이다. + +→ **판단 부탁**: (a) 현행 유지(일관성 우선, 낭비 1회 허용) / (b) flush 뒤로 +이동(낭비 제거, `C-7` 계약에 예외 하나 명시) / (c) flush 루프를 "부기 phase → +마운트 phase" 둘로 쪼개 둘 다 만족(가장 정확하지만 재귀 구조를 손대야 해서 +비용이 큼). **저는 (a)를 추천**한다 — 낭비가 "뒤에 형제가 있을 때 offset이 두 번 +`Set`되는" 것뿐이고, `C-7`을 방금 일반 계약으로 올린 직후에 예외를 만드는 게 +더 비싸 보인다. + +## F-5. 이번에 base에 반영한 것 + +| 항목 | 반영 내용 | 대상 | +|---|---|---| +| `B-1` | (A) 분기 = 교체, `retractFrom` = stack-down 구분 명시 | `dispatch-core-plan.md` | +| `B-3` | `MaybeWrapHandler` 가상 위반 예시 추가 | `dispatch-core-plan.md` | +| `B-4` | `Brand` 존재 이유 한 줄 + duck-typing 근거 2분할 | `brand-plan.md` | +| `C-3` | flatten의 정확한 형태(in-place, `ProcessedModifier` 소진, 인라인 우선이 `~= nil`로 성립) — **반복 방향만 역순으로 정정**(`F-4-2`) | `modifier-plan.md` | +| `C-4` | `destroySlotTree`의 명시적 `releaseOwner` 제거 + 왜 `rawRemove`와 갈리는지 | `slot-plan.md` | +| `C-6` | `recompute`의 `sourceList[i] == nil`을 skip → **즉시 `error`** | `slot-plan.md`, `dispatch-core-plan.md` | +| `C-7` | **"부기가 물리 트리 조작보다 항상 먼저"를 일반 계약으로 승격** — 각 `raw*`에 어떻게 적용되는지(빼기는 물리 먼저/넣기는 부기 먼저가 같은 원칙의 두 얼굴)와, 프레임 경계가 어차피 안 낀다는 진짜 근거까지 | `dispatch-core-plan.md` | + +**안 고친 것**: `C-1`/`C-2`(F-3 동의 대기), `C-5`(F-4-3 판단 대기), +`B-7`의 base 표현(`ownerKey` vs "owner" — F-3/F-4가 정리되면 같이). diff --git a/.claude/todos.md b/.claude/todos.md index 29eb779..bcad2cc 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -8,14 +8,16 @@ 00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — 1·2·3라운드는 전부 `base/`에 반영 완료, [2026-08-19 신설] 4라운드는 문항지 작성만 끝나고 사용자 회신 대기 중.** - **4라운드 — [2026-08-20] 회신 1차 처리 완료, 재질문 대기.** 문항지는 + **4라운드 — [2026-08-21] 회신 2차 처리 완료, F절만 열려 있음.** 문항지는 `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 `-response.md`, 처리 결과·재질문·판단 대기 항목은 **`-followup.md`가 소스**(여기서 목록을 세지 않음). 회신 중 판단이 명확했던 것은 그 자리에서 - `base/`에 반영했고, **설명 부족으로 되물어야 하는 것(B절)과 파급이 커서 - 임의 결정이 위험한 것(C절)이 남아 있다** — 특히 `C-1`(`KeyGone` 센티널 - 신설)과 `C-2`(`:List`의 "밀려난 prev는 dispose"가 `state` - 의미론과 충돌)가 나머지를 막고 있다. 아래는 그 회신 전 서술: + `base/`에 반영했다. **B절(설명 보강 재질문)은 2차 회신으로 전부 확인됐고, + C절 결정도 대부분 반영 완료** — 지금 열려 있는 건 followup **F절**뿐이다: + `F-3`(`KeyGone` 홀드 + `Owned` 설치 플래그 설계 제안에 대한 확인 — + 여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/`dispose` 재귀가 한 번에 닫힘), + `F-4`(단일 일반화 `for` 전환 여부 / flatten 반복 방향 / `setLength` 위치). + 아래는 그 회신 전 서술: **(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로