design: QA 4라운드 2차 회신 반영 — B절 확인분 + C절 결정 7건
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 <me@qwreey.moe>
This commit is contained in:
parent
9642cf8f18
commit
f36bdcbe5d
6 changed files with 563 additions and 37 deletions
|
|
@ -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되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 세션] 그
|
||||
재바인드 기능 자체가 기각**되어 질문이 없어짐
|
||||
|
|
|
|||
|
|
@ -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<Slot>` 교체는 **파괴가 아니라 언마운트** — `state<Frame>`와 완전히 동일
|
||||
|
||||
|
|
@ -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<state<Frame>>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된
|
||||
것"으로 수용**(사용자 판단) — `state<state<Tag>>`와 같은 범주로,
|
||||
|
|
|
|||
|
|
@ -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<Self,P>`/
|
|||
주면 된다. **`C-1`(KeyGone)과 `C-2`(dispose vs `state<Frame>` 충돌)가
|
||||
가장 파급이 크고 나머지를 막고 있다.**
|
||||
- **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가 정리되면 같이).
|
||||
|
|
|
|||
|
|
@ -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<Frame>`
|
||||
의미론과 충돌)가 나머지를 막고 있다. 아래는 그 회신 전 서술:
|
||||
`base/`에 반영했다. **B절(설명 보강 재질문)은 2차 회신으로 전부 확인됐고,
|
||||
C절 결정도 대부분 반영 완료** — 지금 열려 있는 건 followup **F절**뿐이다:
|
||||
`F-3`(`KeyGone` 홀드 + `Owned` 설치 플래그 설계 제안에 대한 확인 —
|
||||
여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/`dispose` 재귀가 한 번에 닫힘),
|
||||
`F-4`(단일 일반화 `for` 전환 여부 / flatten 반복 방향 / `setLength` 위치).
|
||||
아래는 그 회신 전 서술:
|
||||
|
||||
**(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을
|
||||
계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로
|
||||
|
|
|
|||
Loading…
Reference in a new issue