design: Detach 보존 주체/KeyGone/Owned 확정 + attachSlot 분해

QA 4라운드 followup의 마지막 열린 항목(F-3)이 사용자 회신으로 전량
닫히면서 :List 요소 소유권 모델과 attachSlot 책임 분해를 base/에 반영.

- Detach 보존 주체를 userdata → slot._detached 필드로 전면 정정.
  근거는 gcconn 트릭 — detach된 quad-제작 Instance는 GC 폴백이 없어
  명시적 정리 경로가 필수인데 userdata는 :List에게 opaque라 처분 불가.
  재-Detach는 nop, prev 반환은 재마운트. raw 3형제(rawRemove/rawUnmount/
  rawDetach)로 "파괴하는가"와 "소유권을 놓는가"를 분리.
- KeyGone 센티널 신설 — 키가 사라진 자리도 조용히 처분하지 않고
  updateFn(KeyGone, 0, offset, prev, ud)로 한 번 더 묻는다. owner 사망
  시 최종 정리는 mountSlotTree가 거는 Effect가 담당.
- Owned 설치 플래그 신설 — Detach(사이클 단위)와 직교하는 축.
  state<Frame> 의미론 충돌(C-2)이 이걸로 닫힘.
- attachSlot을 materializeSlotTree(부기) + mountSlotTree(물리)로 분해.
  "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 한
  함수 안에선 동시 만족 불가라는 진단이 근거. 공개 표면은 두 줄짜리
  래퍼로 유지해 호출부 무변경. research/slot-attach-decomposition.md 확정.
- ROADMAP M6의 옛 Detach 서술 2건과 미결 마커 정정, question.md/todos.md
  해소 반영, session/2026-08-21-01 원문 + session-summary 색인 공백 4건 보강.

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-21 11:55:11 +09:00
parent 448b961e7f
commit 9b7f847014
Signed by: qwreey
GPG key ID: D28DB79297A214BD
10 changed files with 726 additions and 186 deletions

View file

@ -167,7 +167,9 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`
재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다**
재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의
`materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다)
**그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** —
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도
Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의
`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려

View file

@ -1381,7 +1381,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
동일하게 적용"* — 즉:
- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`
등록할 때마다 `bk.N``i`로 늘어난다(`Dispatch.drive`의 배열 파트
순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이
순회, `materializeSlotTree`의 등록 배치, Slot의 런타임 단건 `rawAdd` 전부 이
하나의 규칙) — **`Dispatch.setOffsetSource``bk.N`을 건드리지
않는다**, 호출 순서가 항상 `setOffsetSource(i)``setLength(i)`라서
(아래 "`setLength` 구현" 절) `bk.N``setLength`에서만 올려야
@ -1396,7 +1396,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
케이스가 아니라 같은 규칙의 특수한 안정 상태다.
**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치
등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을
등록 중(`Dispatch.drive`/`materializeSlotTree`의 등록 루프)엔 아래 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가
`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로,
배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도
@ -1645,7 +1645,13 @@ position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보
전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로).
**적용 지점 — `Dispatch.drive``attachSlot`, 각각 자기 owner 키로
별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
별도 Blocker**
(**[2026-08-21] `attachSlot` 쪽은 이제 정확히는 그 안의
`materializeSlotTree`다** — `attachSlot`이 "부기만 만드는 재귀"와 "물리만
붙이는 재귀" 둘로 분해되면서 Blocker가 **등록 쪽 하나만** 감싸게 됐고,
그래서 "배치 *등록* 게이팅"이라는 이 절의 정의와 실제 범위가 정확히
일치하게 됐다. 옛 코드는 물리 마운트까지 같이 감싸고 있었음.
`base/slot-plan.md`의 "재귀 메커니즘" 절이 소스): 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위
`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의**
`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 —

View file

@ -471,8 +471,11 @@ releaseOwner(element, slot)
클레임 미접촉) 무조건 error가 맞음 —
top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함.
**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawRemove`/
`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다. `elementOwner`
**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawUnmount`/
`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다(**[표현 정밀화,
2026-08-21]** 여기 `rawRemove`를 같이 적었었는데 그건 파괴 경로다 —
`rawRemove``releaseOwner`는 아래 재정정 기준으로 보면 불필요하지만 요소가
어차피 죽으므로 무해해서 그대로 둔다). `elementOwner`
값도 `SetWeak`이라 "아무에게도 참조되지 않게 되면 소유권 기록도 저절로
사라진다"가 원리적으로는 맞지만, **그게 언제인지가 GC 타이밍에 달려 있어서**
그 전에 같은 element를 다른 곳에 넣으려 하면 "이미 마운트돼 있음" error가
@ -1069,7 +1072,21 @@ lifecycle-pattern.md` "quad는 자신이 만든 Instance의 라이프사이클")
Slot 자신)보다 명시적으로 오래 살아야 하는 값을 담으면 안 됨 — GC만으로
자연히 정리되는 값만 담을 것(plain 값, `Source`/`State` 등), `:Subscribe()`
`Observer`/`Effect`류처럼 명시적 `:Unsubscribe()`가 필요한 값을 담는 건
UB.** `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의
UB.**
**⚠️ [예시 추가, 2026-08-21] quad가 만든 Instance도 "GC만으로 정리되는 값"이
아니다.** 지금까지 이 절은 `:Subscribe()`한 Observer만 예로 들어서 **Instance는
안전해 보였는데, 정반대다** — quad는 자기가 만든 Instance마다 gcconn을 걸고 그
클로저가 `inst`를 캡처하므로("참조를 놓는 것만으로는 회수되지 않고 반드시
`Destroy`로 회수된다", `base/lifecycle-pattern.md`의 "(0)" 절) **`ud`에 담아둔
Instance는 아무도 안 들고 있어도 영원히 남는다.**
- 요소를 잠깐 떼어뒀다 되쓰고 싶으면 **`ud`에 직접 담지 말고 `Detach`
반환**할 것 — 그러면 `slot._detached`가 관리하고 owner가 죽을 때 같이
정리된다(위 "Detach된 요소는 `slot._detached`가 보유한다" 절). `Detach`
생긴 뒤로는 `ud`에 Instance를 담을 이유 자체가 없다.
- 그래도 담겠다면 **정리 책임은 전적으로 사용자**다 — `:List``ud` 안을
들여다보지 않는다. `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의
무언가가 GC 하나만으로 안 죽는다면 그건 곧 leak. 이건 quad 전역
GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 그대로
적용한 것뿐 — 새 원칙 아님.
@ -1104,6 +1121,40 @@ function activateList(self, inst)
local offset = self.Offset
local mounted, userdata, keyIndex = {}, {}, {}
-- [2026-08-21] 한 키의 처분을 실제로 수행하는 공통 로직 — 정상 사이클과
-- 소멸 루프가 같은 걸 쓴다(분기가 두 군데로 갈리면 반드시 어긋남).
local function settle(key, result, detach, pos)
local wasMounted = mounted[key]
local wasDetached = self._detached[key] -- Slot 필드(아래 "Detach된 요소는 `slot._detached`가 보유한다" 절)
local prev = wasMounted or wasDetached
if detach then
-- "이 자리를 비우되 죽이지 마라". 이미 detach 상태면 **nop**.
if wasMounted ~= nil then
rawDetach(self, wasMounted) -- 언마운트하되 **소유권은 유지**
self._detached[key], mounted[key] = wasMounted, nil
end
elseif result == nil then
-- "지워라". Owned=false면 파괴 대신 언마운트만(위 "Owned" 절).
if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
mounted[key], self._detached[key] = nil, nil
elseif result == prev then
if wasDetached ~= nil then
self._detached[key] = nil -- **재마운트** — detach된 걸 되살림
rawAdd(self, result, pos)
mounted[key] = result
elseif keyIndex[key] ~= pos then
rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
end
else
-- 교체 — 밀려난 prev 처분은 Owned가 정한다
if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
self._detached[key] = nil
rawAdd(self, result, pos)
mounted[key] = result
end
end
local function reconcile(items)
local newKeyIndex, seen = {}, {}
local pos = 0 -- 압축된(실제 마운트된) 위치 카운터, raw 루프 인덱스 i와 다름
@ -1115,7 +1166,9 @@ function activateList(self, inst)
end
seen[key] = true
local prev = mounted[key]
-- [2026-08-21] prev는 "이 키의 요소" — 마운트돼 있든 detach돼 있든.
-- 그래서 detach된 걸 그대로 반환하면 재마운트가 된다(settle 참고).
local prev = mounted[key] or self._detached[key]
local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능)
local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key])
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
@ -1132,35 +1185,30 @@ function activateList(self, inst)
pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)
end
if result ~= prev then
-- [재정정, 2026-08-18 구현 전 QA] 세 경로가 갈린다 — 아래
-- "`nil` 리턴은 파괴가 기본" 절이 소스:
-- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만**
-- — state<Frame> 교체와 동형, 지우라고 한 적이 없음.
-- (b) Detach: **언마운트만**, 재사용은 ud가 홀드.
-- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시.
if prev ~= nil then
if result ~= nil or detach then rawUnmount(self, prev)
else rawRemove(self, prev) end
end
if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준
mounted[key] = result
elseif prev ~= nil and keyIndex[key] ~= pos then
rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
end
settle(key, result, detach, pos)
userdata[key] = ud -- result와 무관, 그대로 기록
-- (Detach 재사용은 여기 담긴 { old = ... }가 담당)
newKeyIndex[key] = pos
end
-- [재설계, 2026-08-21] 소멸 루프 — 조용히 파괴하지 않고 **처분을 묻는다**.
-- 아래 "`KeyGone`" 절이 소스.
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
if not seen[key] then
local prev = mounted[key]
if prev ~= nil then rawRemove(self, prev) end -- [재정정, 2026-08-18] 파괴
mounted[key], userdata[key] = nil, nil
local prev = mounted[key] or self._detached[key]
local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key])
if result == None then result = nil end
local detach = (result == Detach)
if detach then result = nil end
if result ~= nil and result == prev then
error("Slot:List — KeyGone에 prev를 그대로 반환할 수 없음(키가 없는데 마운트 유지는 모순). "
.. "계속 들고 있으려면 Detach를 반환할 것")
end
settle(key, result, detach, 0) -- pos는 의미 없음(자리를 안 차지함)
userdata[key] = ud -- 유저가 nil을 반환해야 지워짐
end
end
keyIndex = newKeyIndex
keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다**
end
local data = self._listData
@ -1229,8 +1277,10 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등)
불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도
같이 GC됨(아래 "구독 시점" 절).
- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawRemove`/
`rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째
- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawMove`와, 처분 헬퍼
`releaseElement`(→ `rawRemove` 또는 `rawUnmount`)/`rawDetach`**
(**[2026-08-21]** `Detach` 확정으로 `rawDetach`가, `Owned` 확정으로
`releaseElement`가 추가됨 — 처분 분기가 한 군데(`settle`)로 모였다) (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째
세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을
**부분적으로 되돌림**`nil` 리턴/키 소멸은 다시 **파괴**가 기본이고,
값 교체와 `Detach`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이
@ -1262,40 +1312,114 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
| updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 |
|---|---|---|
| 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state<Frame>` 교체와 동형이고, `Slot { State<Slot> }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State<Slot>` 교체" 절의 확정도 그대로 유지됨 |
| `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
| `Detach` | **언마운트만** + 재사용 대기 | 아래 |
| 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) |
| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state<Frame>` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(위 "`Owned` 옵션" 절) |
| `nil` / `None` | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
| `Detach` | **언마운트만 + `slot._detached`가 계속 보유** | 아래. 이미 detach 상태면 **nop** |
| `prev`를 그대로 반환 | 마운트 중이면 위치만 이동, **detach 중이면 재마운트** | 아래 |
| 키가 데이터에서 사라짐 | **`updateFn`에게 `KeyGone`으로 처분을 묻는다** | **[재설계, 2026-08-21]** 아래 "`KeyGone`" 절 |
**`Detach` — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.**
`filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번
파괴/재생성하는 건 비싸다. 그래서 `updateFn`**`Detach`와 함께 userdata를
반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다:
파괴/재생성하는 건 비싸다. 그래서 `updateFn`**`Detach`를 반환**하면 그
자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다:
```lua
-- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다
return Detach, { old = prev, source = ... }
return Detach
```
- **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐
`userdata[key]`는 그대로 기록하므로(위 의사코드), 반환한 테이블 안의
`old`가 그 요소를 강하게 붙잡아 GC를 막는다. 다음 사이클에 `updateFn`
같은 `userdata`를 다섯 번째 인자로 다시 받으므로, 거기서 `old`를 꺼내
그대로 반환하면 **재마운트**된다(`rawAdd` 경로).
- **userdata는 그 키가 데이터에 남아 있는 한, 명시적으로 `nil`을 반환하기
전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다.
- **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서
발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를
**둘 다** 지우는데, `Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미
`nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는
**파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이
된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고
못박은 것과도, 위 "버릴 시점은 `updateFn`이 정한다"와도 어긋난다.
**세 선택지 중 하나를 M8 착수 전에 정할 것**(`question.md` 3번):
(a) 소멸 루프가 `userdata[key].old`도 확인해 `rawRemove`로 파괴,
(b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침),
(c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다.
**지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지.
**[정정, 2026-08-21] 여기에 userdata를 딸려 보낼 필요는 없다.** 옛 서술은
`return Detach, { old = prev }`처럼 사용자가 직접 붙잡는 모양이었으나, 보존
주체가 `slot._detached`로 바뀌면서 다음 사이클에 그 요소가 **`prev`로 그대로
전달**된다 — 그냥 `return prev`하면 재마운트된다. 바로 아래 절.
#### ⭐ Detach된 요소는 `slot._detached`가 보유한다 — `userdata`가 아님 (2026-08-21 확정)
**[전면 정정]** 옛 서술은 *"보존 주체는 `userdata`"* 였다 — `updateFn`
`return Detach, { old = prev }`처럼 자기 `ud`에 담아 붙잡고, 다음 사이클에
그걸 꺼내 반환하면 재마운트된다는 모양. **이건 최종 처분이 불가능해서
폐기**한다(사용자 확정: *"Detach 요소는 slot 안에 보관하는게 내 생각이였어서
… ud 에 넣는거로는 최종 처분이 불가하다에 동의함"*).
**왜 `userdata`로는 안 되나 — 세 가지**:
1. **`:List`가 안을 못 들여다본다.** `userdata`는 계약상 완전히 opaque다
(아래 "`userdata`의 생명주기 제약" 절) — 파괴할 시점이 와도 **뭘 죽여야
하는지 알 방법이 없다.**
2. **소유권이 붕 뜬다.** detach된 요소는 여전히 이 Slot이 관리 중이어야
남이 못 가져간다(`elementOwner`). `ud`에만 있으면 Slot 쪽엔 아무 기록이
없다.
3. **파괴 walk가 안 닿는다.** `destroySlotTree``_elements`를 훑는데
detach된 요소는 거기서 이미 빠졌다 — `ud` 안은 walk 대상이 아니다.
**그리고 이건 "GC가 언젠가 치우겠지"로 넘어갈 수 없다.** quad는 자기가 만든
Instance마다 gcconn을 걸고 **그 클로저가 `inst`를 캡처**하므로("참조를 놓는
것만으로는 회수되지 않고 반드시 `Destroy`로 회수된다",
`base/lifecycle-pattern.md`의 "(0)" 절), `Parent = nil`인 detach 노드는
**아무도 안 들고 있어도 자기 시그널 커넥션이 자기를 살려 영원히 남는다.**
GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다.
**확정된 형태**:
- **`slot._detached: {[key]: T}` — Slot 필드.** 클로저 업밸류가 아니라
필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다. (`mounted`/`userdata`/
`keyIndex``activateList`의 업밸류로 남는 것과 다른 이유 —
**마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.)
- **소유권은 유지된다**`Detach``rawUnmount`(소유권 반납)가 아니라
`rawDetach`(**소유권 유지**)를 쓴다. 아래 "raw 3형제" 참고.
- **`prev`로 그대로 돌려준다** — reconcile이 `mounted[key] or self._detached[key]`
`prev`로 넘기므로, **`updateFn``ud`로 붙잡을 필요가 없다.** 그대로
반환하면 재마운트된다.
- **이미 detach인데 또 `Detach`를 반환하면 nop**(사용자 확정) — 상태가
그대로 유지될 뿐 아무 일도 안 일어난다. 매 사이클 filter에 걸리는 흔한
경로라 여기서 뭔가를 반복하면 안 된다.
- **`ud`에 담는 것 자체는 여전히 자유** — 다만 **보존을 위해 담을 필요가
없어졌고**, 담더라도 그건 사용자 몫이라 `:List`가 정리해주지 않는다
(아래 "`userdata`의 생명주기 제약" 절).
```lua
-- filter에서 걸러진 아이템 — 그냥 Detach만 반환하면 된다
if not shouldShow(item) then
return Detach, ud -- slot._detached가 붙잡아둠, ud로 홀드할 필요 없음
end
-- 다시 보여야 하면 prev를 그대로 반환 → 재마운트
if prev then return prev, ud end
```
#### `KeyGone` — 키가 데이터에서 사라질 때 처분을 묻는다 (2026-08-21 신설)
**옛 갭**: 소멸 루프가 `mounted[key]`/`userdata[key]`를 조용히 지웠는데,
`Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미 `nil`이라 파괴 대상이
아니었다 — **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겼다**
(그리고 위 gcconn 때문에 GC도 안 됐다). 사용자 판정으로 **`updateFn`에게
한 번 더 물어보는 것**으로 해소:
```lua
updateFn(item: T | KeyGone, index, offset, prev, ud)
```
- **`KeyGone``Detach`/`None`과 같은 급의 sentinel**이고, 공개 표면 위치도
같다(패키지 최상위 export, 정의는 Slot 관련 파일 옆 — 아래 `Detach`
"공개 표면 위치 확정" 항목과 동일한 근거).
- **`updateFn``if item == KeyGone then ... end`로 분기**해 처분을 고른다 —
반환값 의미는 정상 사이클과 **완전히 같다**(`nil`=파괴, `Detach`=계속 홀드,
새 값=교체). 그래서 reconcile의 처분 로직(`settle`)이 한 벌로 공유된다.
- **`prev`를 그대로 반환하는 것만 `error`** — 키가 없는데 마운트를 유지하라는
건 모순이다. 계속 들고 있으려면 `Detach`를 반환해야 한다. (다른 CRUD 에러
조건들과 같은 fail-fast 톤.)
- **`index``0`** — 사라진 키는 자리를 안 차지한다. `offset`/`sum`이 이미
0-based 개수라 "아무 자리도 없음"이 `0`으로 자연스럽고, `index: number`라는
시그니처도 안 바뀐다(`nil`을 넣으면 타입이 바뀜). `offset`은 Slot의 것을
그대로 넘긴다(항상 유효).
- **한 번만 묻는다 — 새 규칙이 필요 없다.** 소멸 루프는 **직전 사이클의
`keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번
사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된
것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시
나타나면 `prev`로 부활하고, (b) owner가 죽으면 아래 `Effect`가 정리한다.
- **`userdata`도 유저가 정한다** — `updateFn``nil`을 반환해야 지워진다.
키가 이미 사라졌으므로 다시 물어볼 기회가 없다는 뜻이지만, **owner가 죽으면
전부 같이 사라지므로 영구 누수는 아니다**(사용자 확정: *"대신에 slot 의
소유주가 죽으면 같이 죽는다. ud 도 모두 정리된다"*).
- **이름 확정 — `Detach`(2026-08-19).** 처음엔 가칭 `PopOnly`로 도입됐고
사용자가 *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더
생각해보아야함"*이라고 남겨 `question.md` 용어 정리 항목에 올라가
@ -1364,12 +1488,11 @@ Slot:Single(state, updateFn?, opts?)
- **⚠️ 이름은 `Owned`로 잠정** — `elementOwner`/`claimOwner`/`releaseOwner`와
같은 뿌리라 골랐다. 다른 가칭들과 함께 용어 정리 대기열(`question.md` 1번).
**⚠️ 아직 안 닫힌 짝 항목** — `Detach`로 홀드된 요소를 **어디에 보관하고
언제 파괴하는지**(`slot._detached` 필드 + owner 죽을 때 `Effect`로 정리)와
`KeyGone` 센티널은 별개로 확인 대기 중이다.
`qa-request/pre-implementation-qa-round4-followup.md``F-3` 절이 소스 —
**그게 닫히기 전엔 이 절의 "파괴" 칸을 구현하지 말 것**(무엇을 파괴 대상으로
훑을지가 거기서 정해짐).
**[2026-08-21 짝 항목도 닫힘]** `Detach`로 홀드된 요소의 보관 위치
(`slot._detached` 필드)와 최종 처분(owner 죽을 때 `Effect`), `KeyGone`
센티널까지 같은 라운드에 확정됐다 — 아래 "Detach 요소는 `slot._detached`
보유한다"/"`KeyGone`" 절이 소스. `destroySlotTree``_owned == false`
파괴 대신 언마운트로 빠지는 것도 그 확정의 일부.
- **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가
이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가
@ -1620,97 +1743,118 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
`qa-request/pre-implementation-qa-round3.md``RC-3`/`RC-4` 절.
```lua
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
local function attachSlot(slot, physicalTarget, ownerKey, position)
-- [정정, 2026-08-13 세션] bindLifetime 호출은 여기 없음 — top-level 전용
-- 앵커링은 SlotHandler.process(아래)로 이동. attachSlot 자신은 이제
-- top-level/nested 어느 깊이에서 불려도 완전히 동일하게 동작하는 순수 구조적
-- mount 로직만 담당(레이어 구분은 오직 이 함수를 부르는 쪽의 책임) — retract
-- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은
-- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에
-- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음.
-- quad-base, Slot.luau
-- [전면 재작성, 2026-08-21 구현 전 QA 4라운드 확정] 옛 단일 `attachSlot`
-- **비공개 재귀 둘 + 얇은 공개 진입점**으로 분해. 공개 표면(이름/시그니처/
-- 호출부 셋)은 하나도 안 바뀐다 — 쪼갠 건 함수가 아니라 **재귀**다.
-- 근거와 대안 비교는 `research/slot-attach-decomposition.md`.
-- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다.
local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1)
local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산
Dispatch.setOffsetSource(ownerKey, position, 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
activateList(slot, physicalTarget) -- reconcile이 채우는 건 `_elements`뿐 — 물리 마운트는 안 함(위 참고)
end
-- `_mounted`는 여전히 false — reconcile의 rawAdd가 "아직 마운트 전"
-- 경로(= `_elements`에만 넣고 끝)를 타야 함(RC-3/RC-4). 이제 이 조건이
-- **함수 경계로 강제**된다: `_mounted`를 켜는 코드가 이 함수엔 아예 없음.
if slot._listed then activateList(slot, physicalTarget) end
slot._mounted = true
slot._mountedInst = physicalTarget
-- **[정정, 2026-08-18 3라운드]** `slot.Length`는 이 시점에 아직
-- "확정된 값"이 아니다 — 최종 값은 아래 flush 루프 끝의 `recompute`
-- 매긴다. 여기서 넘기는 건 값이 아니라 **State 객체 자신**이라 무해함:
-- 부모는 이 객체를 구독해뒀다가, 그 값이 나중에(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의
-- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용.
-- 자식 부기만, 재귀로 길이를 bottom-up 확정.
-- Blocker가 감싸는 게 이제 **등록뿐**이라 "배치 등록 게이팅"이라는
-- 정의와 범위가 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음).
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
blocker:On()
for i, element in ipairs(slot._elements) do
if isSlot(element) then
attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신
-- 재귀 — 자식이 자기 끝에서 setLength(slot, i, 자기Length)까지 하고 옴
materializeSlotTree(element, physicalTarget, slot, i)
else
-- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도
-- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1.
-- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None,
-- length는 상수 1. 순서는 늘 offsetSource → setLength(C4).
Dispatch.setOffsetSource(slot, i, None)
Dispatch.setLength(slot, i, 1)
element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행
end
end
blocker:OffWithoutEmit()
local bk = getBookkeeping(slot)
if bk then recompute(slot, bk) end -- 여기서 slot.Length가 비로소 진짜 값으로 확정됨
if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정
-- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에
-- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에
-- 만족시킬 수 없었던 지점.
Dispatch.setLength(ownerKey, position, slot.Length)
end
-- (2) 물리만 붙인다. 부기를 단 한 줄도 안 건드린다 → Blocker 불필요.
local function mountSlotTree(slot, physicalTarget)
slot._mounted = true
slot._mountedInst = physicalTarget
-- [2026-08-21] Detach 홀드분의 최종 처분 경로 — physicalTarget이 죽을 때
-- `slot._detached`를 비운다(아래 "Detach 요소는 slot._detached가 보유한다" 절).
-- Effect가 유일한 도구인 이유: bindLifetime은 "실행해도 되는가"만 게이팅할 뿐
-- 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`).
slot._detachCleanup = Effect(function()
return function()
for key, element in pairs(slot._detached) do
if slot._owned ~= false then
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
end
slot._detached[key] = nil
end
end
end)
bindLifetime(physicalTarget, slot._detachCleanup)
for i, element in ipairs(slot._elements) do
if isSlot(element) then mountSlotTree(element, physicalTarget)
else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행
end
end
-- (3) 공개 진입점 — 이름/시그니처/호출부 전부 옛것 그대로. 몸통만 두 줄.
local function attachSlot(slot, physicalTarget, ownerKey, position)
materializeSlotTree(slot, physicalTarget, ownerKey, position)
mountSlotTree(slot, physicalTarget)
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`의 "확인만 하고 새 결함
없음" 절.
**왜 쪼갰나 — 이득 넷**(상세와 기각된 대안은
`research/slot-attach-decomposition.md`):
1. **C6와 C7이 처음으로 동시에 만족된다.** 옛 코드는 `setLength`의 자리가
하나뿐이라 "최종값으로 등록"(C6)과 "부기가 물리보다 먼저"(C7) 중 하나를
포기해야 했다 — C7을 지키고 `Length = 0`으로 등록한 뒤 자기 교정하는
쪽이었다. 분해하면 둘 다 성립하고, **배치 밖 재마운트의 부모 `recompute`
2회 → 1회**로 준다(아래 "해소" 참고).
2. **순서 제약이 줄 순서가 아니라 함수 경계로 강제된다.** `RC-1`/`RC-3`/`RC-4`가
전부 "한 함수 안에서 줄 순서를 잘못 잡아" 난 버그였는데, `_mounted`를 켜는
코드가 `materializeSlotTree`엔 아예 없으므로 그 실수 클래스가 구조적으로
사라진다. **사용자 확정 근거**: *"이게 하나의 큰 복잡한 복합 함수라 여러
session 간의 실수가 발생하던 부분이고 … 지금 의사코드를 건들이는 비용이,
추후 실수가 누적되는 비용보다 싸다고 생각함."*
3. **`_mounted`의 의미가 정직해진다** — 옛 코드에선 "`activateList`는 지났고
flush는 아직"이라는 중간 시점이었는데, 이제 문자 그대로 "mount 단계를
지났는가"다.
4. **Blocker의 범위가 정의와 일치하고, 백엔드에 seam이 생긴다**
`mountSlotTree`가 부기를 안 건드리는 순수 walk라, 일괄 삽입이 유리한
백엔드(웹 `DocumentFragment` 등)가 **이 함수 하나만** 갈아끼울 수 있다.
**⚠️ 바뀌는 관측 가능한 동작 하나 — 물리 마운트가 "부기 완료 후 일괄"이 된다.**
`Parent` 대입 **순서 자체는 동일**(둘 다 깊이 우선 같은 순서)하지만, 옛
코드는 부기와 물리가 인터리브됐고 지금은 부기가 전부 끝난 뒤 물리가 몰린다.
`Parent` 대입은 `ChildAdded`/`DescendantAdded`를 **동기 발화**시키므로 사용자
핸들러가 이 차이를 관측할 수 있는데, **분해 쪽이 더 정확하다** — 첫
`ChildAdded`가 뜰 때 서브트리 전체의 `Length`/`Offset`이 이미 최종값이다(옛
코드는 `inner.Length == 0`인 미완성 스냅샷을 보여줬다). `slot.Length`
구독자도 `0` → 최종 두 번이 아니라 최종값으로 한 번 발화한다.
**✅ [해소, 2026-08-21] 옛 "좁은 엣지 케이스"는 이 분해로 사라졌다.**
여기 있던 ⚠️ 항목(배치 밖 단독 재마운트 시 부모 `recompute`가 아직 안 굳은
`slot.Length`로 한 번 헛도는 것)은 `setLength``materializeSlotTree` 끝으로
가면서 **처음부터 최종값**이 되어 발생 경로가 없어졌다. 트레이싱 원문은
`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함 없음" 절.
**최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:**
```lua
@ -1726,7 +1870,7 @@ if isSlot(element) and self._mounted then
attachSlot(element, self._mountedInst, self, index)
end
-- self가 아직 마운트 전이면 _elements에만 들어가고, self가 나중에
-- attachSlot될 때 위 flush 루프가 처리
-- attachSlot될 때 materializeSlotTree/mountSlotTree의 루프가 처리
```
**이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인,
@ -1772,6 +1916,16 @@ local function unmountSlotTree(slot)
unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제
end
end
-- [2026-08-21] Detach 정리용 Effect도 같이 푼다 — 안 풀면 **옛
-- physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이 Slot의
-- `_detached`를 파괴**한다(포탈 경로에서 실제로 터짐). 재마운트 때
-- mountSlotTree가 새 target에 다시 건다.
if slot._detachCleanup then
unbindLifetime(slot._detachCleanup)
slot._detachCleanup = nil
end
-- `slot._detached`**안 건드린다** — 언마운트는 파괴가 아니고,
-- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유).
slot._mounted, slot._mountedInst = false, nil
-- [정정, 2026-08-20 `SL-75`] slot.Offset은 건드리지 않는다 — nil로 되돌리면
-- 그 Source를 이미 구독 중인 다운스트림이 영구히 끊긴다(포탈이 깨짐).
@ -1781,6 +1935,12 @@ local function unmountSlotTree(slot)
end
local function destroySlotTree(slot)
-- [2026-08-21] Owned=false면 이 Slot은 요소를 만든 적이 없다 — 파괴 대신
-- 언마운트만(위 "`Owned` 옵션" 절). `Slot:Add(state)` sugar가 그 경우.
if slot._owned == false then
unmountSlotTree(slot)
return
end
for i, element in ipairs(slot._elements) do
-- [재정정, 2026-08-20 구현 전 QA 4라운드 `C-4`] 여기서 releaseOwner를
-- 명시적으로 부르지 **않는다** — 2026-08-13 감사가 넣었던 것을 되돌림.
@ -1791,6 +1951,17 @@ local function destroySlotTree(slot)
element:Destroy()
end
end
-- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`
-- 없으므로(rawDetach가 뺐음) 위 루프가 못 닿는다. **`dispose`가 재귀적으로
-- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절).
for key, element in pairs(slot._detached) do
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
slot._detached[key] = nil
end
if slot._detachCleanup then
unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음
slot._detachCleanup = nil
end
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
if bk then
for i, observer in pairs(bk.observers) do
@ -1830,6 +2001,41 @@ function rawUnmount(self, index)
recompute(self, bk)
end
-- [신설, 2026-08-21] rawUnmount의 "소유권 유지" 짝 — `Detach`가 쓴다.
-- rawUnmount와 **딱 하나만 다름: releaseOwner를 안 부른다.**
-- detach된 요소는 여전히 이 Slot이 들고 있으므로(slot._detached) 소유권을
-- 놓으면 남이 가져갈 수 있게 되어 "다중 마운트 금지" 불변식이 깨진다.
function rawDetach(self, index)
local element = self._elements[index]
local bk = getBookkeeping(self)
if bk.observers[index] then
unbindLifetime(bk.observers[index])
end
-- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이
if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end
spliceArraysDown(self, index)
recompute(self, bk)
end
-- [신설, 2026-08-21] 밀려나거나 지워지는 요소의 처분 — `Owned`가 정한다
-- (위 "`Owned` 옵션" 절). detach 중이던 요소는 이미 `_elements`에 없으므로
-- spliceArraysDown/recompute가 필요 없어 경로가 갈린다.
function releaseElement(self, element, wasDetached)
if wasDetached then
if self._owned ~= false then
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
end
return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음
end
if self._owned ~= false then rawRemove(self, element) -- 파괴
else rawUnmount(self, element) end -- 언마운트만(사용자 것)
end
-- **raw 3형제 — 갈리는 축이 둘(파괴하는가 / 소유권을 놓는가)**:
-- rawRemove : 소유권 반납 + **파괴**
-- rawUnmount: 소유권 반납 + 파괴 안 함 ← 요소를 살려서 내보내는 경로
-- rawDetach : **소유권 유지** + 파괴 안 함 ← 내가 계속 들고 있는 경로
function rawRemove(self, index)
local element = self._elements[index]
local bk = getBookkeeping(self)
@ -2131,6 +2337,13 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로
요구되고 있으면 파괴를 거부하고 즉시 `error`.** 떼어내주지 않음 —
떼어내는 건 `Set`(언마운트)의 몫이고, `dispose`는 그 뒤에 부르는 것.
아무도 요구하지 않는 상태면 실제로 파괴(Slot이면 하위까지 재귀).
**[확인 완료, 2026-08-21] Slot-in-Slot에서도 재귀가 성립한다** — 파괴 walk는
`destroySlotTree`이고 그게 `_elements`를 훑다 `isSlot(element)`면 재귀하며,
**`_detached`(Detach로 홀드 중인 것)까지 같은 함수가 훑는다**(위 코드).
`_owned == false`면 파괴 대신 언마운트로 빠지는 것도 그 안에 있다. 즉
`dispose(slot)` 한 번으로 서브트리 전체 + 홀드분 전체가 정리된다 —
**남는 예외는 `userdata` 안에 사용자가 직접 넣어둔 것뿐**이고, 그건
위 "`userdata`의 생명주기 제약" 절대로 사용자 책임이다.
- **왜 거부가 맞는가(사용자)**: "실제로 클리어 하거나 Destroy 해도
로블록스엔 에러 안 나는데, quad에선 데이터 구조가 깨지는 일이니까요."
엔진은 조용히 넘어가지만 quad의 `_elements`/`lengthList`/`sourceList`/

View file

@ -1,8 +1,13 @@
# 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문
**상태**: **[2026-08-21] 3차 처리 완료 — 아래 G절이 최신.**
**상태**: **[2026-08-21] 4차 처리로 종결 — 아래 H절이 최신이자 마지막.**
`F-3`이 전량 확인됐고 `attachSlot` 분해도 확정돼 `base/`에 전부 반영됐다.
**이 followup에 열린 질문은 남아있지 않다.** 5라운드 문항지는 만들지
않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록.
**[2026-08-21] 3차 처리 — 아래 G절.**
`F-4`는 전부 닫혔고(`F-4-3`은 `research/slot-attach-decomposition.md`로 넘어감),
**남은 건 `F-3`의 (1)~(3)과 "+"뿐**(요약은 `G-3`).
그 시점에 남았던 건 `F-3`의 (1)~(3)과 "+"였다(요약은 `G-3`, H절에서 닫힘).
**[2026-08-21] 2차 처리 — 아래 F절.**
B절은 전부 확인됐고 C절 결정도 대부분 반영됐다. **지금 열려 있는 건 F절의
@ -1069,3 +1074,104 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" =
→ **여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/"+"가 한 번에 닫히고, 그때
`base/` 반영과 `attachSlot` 분해 논의를 이어서 하면 된다.**
---
# H절 — 4차 처리 (2026-08-21): `F-3` 전량 확인 + 함수 분해 확정, `base/` 반영 완료
**입력**: 사용자 회신 — "Detach 요소는 slot 안에 보관하는게 내 생각이였어서
(2) 제안에 동의. ud 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach
인데 또 detach 를 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를
사용하게 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘.
그런데 함수 분해는 확정해도 좋을것 같음. 이게 하나의 큰 복잡한 복합 함수라
여러 session 간의 실수가 발생하던 부분이고, 지금 적절한 방향으로 이동하지
않으면 계속 실수에 의한 시간/기술비용이 축적될것 같음. 지금 의사코드를
건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함."
이걸로 **`G-3``F-3` (1)~(5)와 `G-2`의 분해 결정이 한 번에 닫혔다.**
아래는 실제로 `base/`에 들어간 것.
## H-1. `Detach` 보존 주체 — `userdata``slot._detached` (확정·반영)
- `base/slot-plan.md`**"Detach된 요소는 `slot._detached`가 보유한다"** 절
신설. 옛 서술(보존은 반환한 `userdata`가 담당)은 그 자리에서 정정.
- **raw 3형제로 분화**`rawRemove`(소유권 해제 + 파괴) /
`rawUnmount`(소유권 해제 + 파괴 안 함, 요소가 떠남) /
**`rawDetach`(소유권 **유지** + 파괴 안 함, 내가 계속 들고 있음)**.
`Detach` 경로는 `rawDetach`를 쓴다 — 소유권을 놓으면 `destroySlotTree`
못 줍는다.
- **재-`Detach`는 nop**(이미 `_detached`에 있으면 아무것도 안 함),
**`prev`를 그대로 반환하면 재마운트**(`_detached`에서 빼고 `rawAdd`).
둘 다 `settle()` 안에서 `wasDetached` 분기로 처리 — 정상 사이클과 소멸
루프가 같은 함수를 공유하므로 두 경로가 갈라질 수 없다.
- `releaseElement(self, element, wasDetached)` — detached 상태에서 처분될
땐 이미 소유권을 들고 있으므로 `rawRemove`가 아니라 바로 파괴
(`_owned ~= false`일 때).
## H-2. `KeyGone` 센티널 (확정·반영)
- 키가 데이터에서 사라진 자리는 조용히 처분하지 않고
**`updateFn(KeyGone, 0, offset, prev, userdata[key])`로 한 번 더 묻는다.**
반환은 정상 사이클과 같은 `settle()`로 흘린다.
- **`index``0`**(자리가 없어졌으므로 유효한 인덱스가 없음),
**`KeyGone``prev`를 그대로 반환하면 `error`**(자리 없는 요소를 유지할
방법이 없음) — `G-3`의 추천 그대로 확정.
- **다시 안 묻기는 자동 성립** — 소멸 루프가 이전 `keyIndex`만 돌기 때문.
- owner가 죽을 때의 최종 처분은 `mountSlotTree`가 거는 `Effect`의 cleanup이
`slot._detached`를 전부 비우는 것으로 담당(`_owned == false`면 파괴 안 함).
## H-3. `Owned` 옵션 (확정·반영)
`Detach`(사이클 단위, "내 건데 잠깐 빼둠")와 `Owned`(설치 단위, "애초에 내
게 아님")는 **직교하는 축**이라는 정리가 여기서 확정됐다. `:List`/`:Single`의
설치 시점 플래그(기본 `true`)이고, `false`면 어떤 경로로도 파괴하지 않고
언마운트만 한다 — `state<Frame>`처럼 사용자가 만들어 넘긴 요소용.
`C-2`의 "`:List`의 값 교체 파괴가 `state<Frame>` 의미론과 충돌"이 이걸로
닫힌다(값 교체는 `Owned = true`면 파괴가 맞다 — `updateFn`이 만든 걸 자기
손으로 못 지우면 새는 쪽이 된다).
## H-4. `attachSlot` 분해 (확정·반영)
`research/slot-attach-decomposition.md`를 **확정**으로 승격하고 의사코드를
`base/slot-plan.md`에 반영했다. 결론은 후보 **(B)**:
- **`materializeSlotTree(slot, physicalTarget, ownerKey, position)`** — 부기만.
`Offset` 설치 → `activateList`(`_mounted`는 아직 `false`) → Blocker `On`
→ 자식 재귀/`setLength` → `OffWithoutEmit``recompute`
**마지막에 `setLength(ownerKey, position, slot.Length)`**.
- **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과
`_mounted = true`, `_detachCleanup` Effect 설치만. Blocker 불필요.
- **공개 `attachSlot`은 그 둘을 순서대로 부르는 두 줄** — 이름/시그니처/호출부
전부 그대로라 다른 문서의 참조가 안 깨진다.
이걸로 **C6("부모에게 미는 길이는 최종값이어야 한다")와 C7("부기가 물리보다
먼저 끝난다")가 처음으로 동시에 만족된다** — 한 함수 안에서는
`setLength` 슬롯이 하나뿐이라 원리적으로 불가능했던 조합이다. 부수로 배치
밖 재마운트의 부모 `recompute`가 2회 → 1회.
**사용자가 우려한 관측 가능한 차이**("일자 진행 vs 관측 이후 일괄 등록")는
**`Parent` 대입 순서 자체는 안 바뀌고**, `ChildAdded` 핸들러가 볼 때 서브트리
부기가 이미 최종값이라는 점만 바뀐다 — 옛 코드는 미완성 스냅샷을 보여줬으므로
**엄밀히 더 정확해지는 방향**이다. 분석 원문은 그 문서의 7절.
## H-5. `SL-38` userdata 제약 보강 (반영)
`userdata`에 "GC만으로 정리되는 값만" 담으라는 제약의 예시에 **quad-제작
Instance**를 추가했다 — gcconn 트릭 때문에 참조를 놓아도 회수되지 않아
`Destroy` 없이는 영구 누수다. 기존 예시가 `:Subscribe()` Observer뿐이라
Instance는 안전해 보였다.
## H-6. `ROADMAP.md` 정합 (반영)
M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠️ 미결)를
그대로 서술하고 있어 정정했고, Slot-in-Slot 항목에 분해 결과를 반영했다.
"값 교체와 `Detach` 경로만 파괴 안 함"도 `Owned` 기준으로 재정정.
## H-7. 남은 것
- **`question.md` 3번의 관련 항목**은 이 처리로 전부 닫혔다.
- 사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는
것으로 끝낼 수 있어보임"(D절 회신).
- 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`),
`table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는
`luau-test/STATUS.md`.

View file

@ -164,18 +164,18 @@
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던
요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면
`mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가
`userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고
참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은
`updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`
`old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를
고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정,
2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전
필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가
기본" 절.
- **[신설, 2026-08-21 구현 전 QA 4라운드] `attachSlot` 책임 분해** —
- **[해소, 2026-08-21] `Detach` 홀드 중 키가 사라졌을 때의 처분** —
선택지 (c)로 확정: `updateFn`**`KeyGone`으로 한 번 더 불러 처분을
묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라
**`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가
닿고 소유권도 유지됨), owner가 죽으면 `Effect`가 정리한다. 원래 갭이
치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가
**GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`
"Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절.
- **[해소, 2026-08-21] `attachSlot` 책임 분해** — **(B) 분해 채택으로 확정**,
`base/slot-plan.md`에 반영 완료(`materializeSlotTree`/`mountSlotTree`/얇은
`attachSlot`). 근거 기록은 `research/slot-attach-decomposition.md`.
아래는 그 열려 있던 시점의 서술:
함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치
게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의
최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에

View file

@ -1,7 +1,18 @@
# `attachSlot` 책임 분해 — 확장 논의 준비 자료
**상태**: research — **논의 전 준비 자료.** 아무것도 확정하지 않았고, 다음
논의가 바로 시작될 수 있게 **지금 무엇이 얽혀 있는지**를 한 곳에 모은 것.
**상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`
반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한
설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` /
`mountSlotTree` / 얇은 `attachSlot`)이 소스다.
**사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게
하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금
적절한 방향으로 이동하지 않으면 계속 실수에 의한 시간/기술비용이 축적될것
같음. 지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고
생각함."*
아래는 그 결론에 이른 조사/논거를 그대로 보존한 것 — 원래 서술은 "논의 전
준비 자료"였다.
**왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`
flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가
@ -9,8 +20,8 @@ flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자
attachSlot 되는게 맞을지도. **attachSlot 의 기능이 너무 다양해진게
문제같음.** 이 부분에 있어서는 확장 논의를 하게 준비해두자."*
정본은 여전히 `base/slot-plan.md`의 "재귀 메커니즘" 절 —
**이 문서가 그 확정을 대체하지 않는다.**
정본은 `base/slot-plan.md`의 "재귀 메커니즘" 절 — **이 문서가 그 확정을
대체하지 않는다.**
---

View file

@ -1609,3 +1609,47 @@ Luau 함정을 발견해 `typing-limits.md` §6으로 승격.
(`type function`은 outer local 참조 불가, cross-package엔 `export type
function` + 이중 꺾쇠 제네릭 인스턴스화 필요). 핸드오버 감사 2라운드로
구 시그니처 잔존/개수 하드코딩 8건 발견·수정 후 커밋.
## 2026-08-19 — 구현 전 QA 4라운드 문항지 작성 (회신 대기)
원문: `session/2026-08-19-04-qa-round4-questionnaire.md`
사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 …
서브에이전트는 쓰지 말아줘")으로 `base/` 확정 주장 전체를 한 맥락에서 읽으며
"예가 나와야 정상인 문항"으로 전수 문항화. **설계 결정도 정정도 하나도 안
내리고** 문항지(`qa-request/pre-implementation-qa-round4.md`)만 남긴 채 회신
대기로 끝난 세션.
## 2026-08-19 — 핸드오버 준비, `session-summary.md`/`ROADMAP.md` stale 대청소
원문: `session/2026-08-19-09-handover-prep-roadmap-status-sync.md`
`quad-roblox-types` 언급 확인 요청에서 시작했으나 훨씬 큰 공백 둘을 발견 —
이 색인에 당일 세션 5개(04~08)가 통째로 빠져 있었고,
`CLAUDE.md`/`project-context.md`/`ROADMAP.md`가 "구현 아직 시작 전"이라는
낡은 전제를 깔고 있었다(실제로는 M0/M1이 이미 완료·커밋됨). 둘 다 즉시
반영하고 감사 라운드로 재검증.
## 2026-08-20 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합
원문: `session/2026-08-20-01-qa-round4-response-processing.md`
업스트림 12커밋(pesde 전환, mise/selene, RunInit 재설계, quad-types/
type-version-check 신설, M0 스파이크)을 먼저 rebase로 병합한 뒤, 사용자
회신을 (a) 바로 반영 / (b) 설명 보강 후 재질문 / (c) 사용자 판단 필요 /
(d) 조사해서 답이 나옴으로 갈라 (a)만 `base/`에 반영. 나머지는
`pre-implementation-qa-round4-followup.md`로 정리해 남김.
## 2026-08-21 — `Detach` 보존 주체/`KeyGone`/`Owned` 확정, `attachSlot` 분해
원문: `session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md`
QA 4라운드의 마지막 열린 항목이 닫힌 세션. gcconn 트릭 때문에 detach된
quad-제작 Instance는 **GC 폴백이 없다**는 걸 근거로 보존 주체를
`userdata`**`slot._detached` 필드**로 뒤집고, 키 소멸 처분을 **`KeyGone`
센티널**로 확정(재-`Detach`는 nop, `prev` 반환은 재마운트). `Detach`(사이클
단위)와 **`Owned`**(설치 단위)를 직교 축으로 분리해 `state<Frame>` 의미론
충돌도 해소. 같이 **`attachSlot``materializeSlotTree`+`mountSlotTree`로
분해** — "부모에게 미는 길이는 최종값"과 "부기가 물리보다 먼저"가 한 함수
안에선 동시 만족 불가라는 진단이 근거였고, 사용자가 "지금 의사코드를
건들이는 비용이 추후 실수가 누적되는 비용보다 싸다"로 확정.

View file

@ -0,0 +1,139 @@
# 2026-08-21-01 — `Detach` 보존 주체 / `KeyGone` / `Owned` 확정, `attachSlot` 분해
**한 줄**: QA 4라운드 followup의 마지막 열린 항목(`F-3`)이 사용자 회신으로
전량 닫히면서, `:List`의 요소 소유권 모델(`slot._detached` + `KeyGone` +
`Owned`)과 `attachSlot`의 책임 분해(`materializeSlotTree`/`mountSlotTree`)가
확정돼 `base/slot-plan.md`에 반영됐다.
**소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고,
처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.md`
H절, 분해 근거는 `research/slot-attach-decomposition.md`. 이 파일은 그
결정들이 **어떤 논의를 거쳐 나왔는지**의 원문 기록이다.
---
## 1. 발단 — `F-3`을 제안하게 된 관측
3차 처리(G절)까지 오면서 `Detach`(옛 `PopOnly`) 경로에 두 개의 구멍이
남아 있었다.
1. **보존 주체가 `userdata`였다.** 기존 문서는 `updateFn`
`Detach, { old = ..., source = ... }`를 반환하면 "보존은 반환한 userdata가
담당"한다고 확정해뒀다.
2. **키가 데이터에서 사라졌을 때의 처분이 미결**이었다(`question.md` 3번,
`ROADMAP.md` M6에 ⚠️로 박혀 있었음).
2번을 파다가 1번이 함께 무너졌다. 결정적인 근거는 **gcconn 트릭**이다 —
`base/lifecycle-pattern.md`가 확정해둔 대로 quad가 만든 모든 Instance는
`GetPropertyChangedSignal("ClassName")` 커넥션의 클로저가 자기 자신과
`gchold`를 캡처하므로 **참조를 놓는 것만으로는 회수되지 않는다.**
detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어버리면
**GC 폴백이 아예 없이 영구 누수**다. `userdata``:List`에게 opaque라
"이 안에 뭐가 들었으니 죽여라"를 알 수 없다.
그래서 세 가지를 묶어 제안했다: (1) 보존은 Slot 필드가 한다, (2) owner가
죽을 때의 정리는 `Effect`가 한다, (3) 키가 사라지면 `updateFn`에게 한 번
더 묻는다.
## 2. 사용자 회신 — 두 가지를 더 얹었다
> "Detach 요소는 slot 안에 보관하는게 내 생각이였어서 (2) 제안에 동의. ud
> 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach 인데 또 detach 를
> 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 사용하게
> 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘."
여기서 **"이미 detach인데 또 detach → nop"**과 **"`prev`를 그대로 반환 →
재마운트"**가 확정됐다. 이 둘은 원래 제안에 없던 것으로, 보존 주체가
`slot._detached`로 옮겨간 순간 **`ud`에 홀드할 필요 자체가 없어진다**는
따름정리를 사용자가 먼저 짚은 것이다 — reconcile이 `prev`로 그대로
돌려주니까 사용자 코드가 `old``ud`에 담아 다니지 않아도 된다.
## 3. 반영 — `settle()`이 두 경로를 강제로 합친다
구현상 가장 중요한 판단은 **정상 사이클과 소멸 루프가 같은 처분 함수를
공유하게 만든 것**이다. `settle(key, result, detach, pos)` 하나가
`wasMounted`/`wasDetached`를 함께 보고 분기한다:
- `detach`면 → `rawDetach`(언마운트하되 **소유권 유지**), `_detached`로 이동.
이미 `_detached`에 있으면 `wasMounted == nil`이라 자연히 nop.
- `result == prev`인데 `wasDetached`면 → `_detached`에서 빼고 `rawAdd` = 재마운트.
- `result == nil`이면 → `releaseElement(self, prev, wasDetached ~= nil)`.
두 경로가 갈라질 수 없게 만든 이유는 명확하다. 이 코퍼스에서 반복적으로
났던 버그(`RC-1`/`RC-3`/`RC-4`)가 전부 **"비슷한 두 경로 중 한쪽만 고쳤다"**
계열이었다.
`rawRemove`(소유권 해제 + 파괴) / `rawUnmount`(소유권 해제 + 파괴 안 함) /
**`rawDetach`(소유권 유지 + 파괴 안 함)** 3형제로 분화한 것도 같은 이유 —
"파괴하는가"와 "소유권을 놓는가"가 원래 한 축에 뭉쳐 있었는데, `Detach`
정확히 **파괴 안 하면서 소유권은 유지**하는 조합이라 기존 둘로는 표현이
안 됐다.
## 4. `Owned``Detach`와 직교하는 두 번째 축
`C-2`(`:List`의 "밀려난 prev는 dispose"가 `state<Frame>` 의미론과 충돌)를
닫은 것이 이 축이다. 정리하면:
- **`Detach`** = 사이클 단위. "내 건데 잠깐 빼둠."
- **`Owned`** = 설치 단위. "애초에 내 게 아님."
이 둘이 직교한다는 걸 명시하고 나니 충돌이 사라졌다 — 값 교체 시 파괴는
`Owned = true`일 때 **맞다**(그 요소는 `updateFn`이 만들었고, `:List`
안 지우면 아무도 못 지운다). 사용자가 `state<Frame>`에 담아 넘긴 요소는
`Owned = false`로 설치되고 어떤 경로로도 파괴되지 않는다.
## 5. `attachSlot` 분해 — 사용자가 비용 논거로 확정
3차 처리에서 `F-4-3`(`setLength` 위치)을 물었을 때, 사용자가 문제를 더 크게
재정의했다: "attachSlot 의 기능이 너무 다양해진게 문제같음."
핵심 진단은 **C6과 C7이 한 함수 안에서 동시에 만족될 수 없다**는 것이었다:
- **C6**: 부모에게 미는 길이(`setLength(ownerKey, position, ...)`)는
flush가 끝나야 정해지는 **최종값**이어야 한다.
- **C7**: 부기는 물리 트리 조작보다 항상 먼저 끝나야 한다.
한 함수엔 `setLength` 슬롯이 하나뿐이라, 앞에 두면 C6 위반(부모가 1회
헛돎), 뒤에 두면 C7 위반이다. **함수를 둘로 쪼개면 각자 하나씩 만족**한다.
사용자가 분해안(B)을 검토하고 낸 판단이 결정적이었다:
> "오히려 안 쪼갤 이유가 정당하지 않아보이는게, 안 쪼갰을 때 구현 상 실수가
> 많아질 위험이 보임."
> "이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던
> 부분이고, 지금 적절한 방향으로 이동하지 않으면 계속 실수에 의한
> 시간/기술비용이 축적될것 같음. 지금 의사코드를 건들이는 비용이, 추후
> 실수가 누적되는 비용보다 싸다고 생각함."
이건 `conventions.md`의 "드문 오용이나 가상의 미래 요구까지
방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 **충돌하지 않는다**
이 분해가 방어하는 건 가상의 미래 요구가 아니라 **이미 세 번 관측된 버그
클래스**(`RC-1`/`RC-3`/`RC-4`, 전부 "줄 순서를 잘못 잡아서")이기 때문이다.
분해하면 그 순서 제약이 줄 순서가 아니라 **함수 경계로 강제**된다.
### 사용자가 스스로 답한 우려
같은 회신에서 사용자가 유일한 우려를 제기하고 스스로 결론까지 냈다:
> "다만 이렇게 된다면 얼마나 nested 든, 하나 마운트되고 하나 마운트되고...
> 각 객체들이 Parent = inst 되어가며 피지컬에 붙어가는 일자 진행인데, 이건
> 관측 이후 일괄 등록이라는 차이가 있는듯. 하지만 이미 리스트 액티베이션이
> 자신의 길이를 정확히 알려면 재귀를 뛰어야하는 부분이고 … 이런 사소한,
> 문제를 야기하기 어려운 동작을 원래와 동치시키기 위해 디버깅이 어려운
> 함수를 만드는건 옳지 않아보임."
확인해보니 **`Parent` 대입 순서 자체는 안 바뀐다** — 바뀌는 건
`ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는
점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는
동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은
`research/slot-attach-decomposition.md` 7절.
## 6. 이 세션이 안 한 것
- **5라운드 문항지를 만들지 않았다** — D절 회신의 "이후 stale 만 잡는것으로
끝낼 수 있어보임"에 따름.
- **스파이크 두 건은 미실행**(`01` 재작성 = 단일 generalized `for`,
`table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`.
- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는
**일부러 미뤘다** — 지금 그게 아프다는 증거가 없다.

View file

@ -5,21 +5,19 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등).
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — 1·2·3라운드는 전부 `base/`에 반영
완료, [2026-08-19 신설] 4라운드는 문항지 작성만 끝나고 사용자 회신 대기 중.**
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~4라운드 전부 `base/`
반영 완료로 종결. 열린 항목 없음.**
**4라운드 — [2026-08-21] 회신 3차 처리 완료, `F-3` 일부만 열려 있음.** 문항지는
**4라운드 — [2026-08-21] 종결.** 문항지는
`.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은
`-response.md`, 처리 결과·재질문·판단 대기 항목은 **`-followup.md`
소스**(여기서 목록을 세지 않음). 회신 중 판단이 명확했던 것은 그 자리에서
`base/`에 반영했다. **B절(설명 보강 재질문)은 2차 회신으로 전부 확인됐고,
C절 결정도 대부분 반영 완료** — 지금 열려 있는 건 followup **F절**뿐이다:
`F-3`**(1)~(3)과 "+"**(detached 요소를 `slot._detached` 필드가 들고
owner 죽을 때 `Effect`로 정리 / `KeyGone` 세부 / `dispose` 재귀) — 요약은
followup `G-3`. `F-3``Owned` 설치 플래그와 `F-4` 셋은 전부 닫혔고,
`F-4-3`(`setLength` 위치)은 **`attachSlot` 책임 분해**라는 더 큰 논의로
넘어가 `research/slot-attach-decomposition.md`에 준비 자료를 만들어뒀다
(M6 착수 전 필요). 아래는 그 회신 전 서술:
`-response.md`, 처리 결과 전량은 **`-followup.md`가 소스**(여기서 목록을
세지 않음 — 마지막 H절이 최신). 4차 처리로 `F-3`이 전량 확인되며
**`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**,
**`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해**
(`materializeSlotTree` + `mountSlotTree`, 근거는
`research/slot-attach-decomposition.md`)가 전부 확정·반영됐다.
**5라운드 문항지는 만들지 않는다** — 사용자 지시("이후 stale 만 잡는것으로
끝낼 수 있어보임"). 아래는 그 회신 전 서술:
**(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을
계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로
@ -86,12 +84,10 @@
- **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면
위치도 `None`과 같은 최상위 export로 같이 확정). 원문은
`archive/question-resolved.md`.
- **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분**
(`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고
참조만 끊김. **[정정, 2026-08-18 `/code-review high``ROADMAP.md`
M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수
전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥
지나칠 위험이 있었음.
- **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 —
**`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는
`slot._detached` 필드가 보유하고 owner 사망 시 `Effect`가 정리.
`base/slot-plan.md`의 "`KeyGone`" 절.
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.

View file

@ -429,7 +429,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함
(자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈).
**쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고
`:List``reconcile`**값 교체와 `Detach` 경로만**.
`:List``reconcile`**`Owned = false` 설치와 `Detach` 경로**
(**[재정정, 2026-08-21]** 값 교체는 `Owned = true`면 파괴가 맞다 —
`updateFn`이 만든 걸 자기 손으로 못 지우기 때문. `state<Frame>`
의미론은 `Owned = false`가 담당).
**여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고
**[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`
`nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의
@ -513,16 +516,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글
아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면
저비용 재사용 경로.
**[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** —
`updateFn``Detach, { old = ..., source = ... }`를 반환하면 그
자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고,
보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내
반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용
경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/
slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절).
**⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를
어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론
필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저,
**[2026-08-18 신설, 이름 2026-08-19 확정, 2026-08-21 보존 주체 정정]
`Detach` 반환 경로** — `updateFn``Detach`를 반환하면 그 자리는
**파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠진다.
**보존 주체는 `userdata`가 아니라 `slot._detached` 필드**(Slot 필드여야
`destroySlotTree` walk가 닿고 소유권도 유지됨 — `ud`로는 최종 처분이
불가능). reconcile이 `prev`로 그대로 돌려주므로 `ud`에 담을 필요 없이
**그대로 반환하면 재마운트**되고, 이미 detach 상태에서 또 `Detach`
**nop**. 언마운트는 소유권을 유지하는 `rawDetach`를 씀(`rawUnmount`
아님). `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. 공개
표면은 `None`과 같이 패키지 최상위 export(`base/slot-plan.md`의
"Detach된 요소는 `slot._detached`가 보유한다" 절).
**[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은
`KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`
한 번 더 물어 처분을 받고, owner가 죽으면 `mountSlotTree`가 건
`Effect``_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절).
**`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본
`true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가
`state`에 담아 넘긴 요소용, `Slot:Add(state)` sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(`prev`류 먼저,
`userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`
`prev`보다 앞이었음).
**`updateFn``index``keyFn`의 raw `index`(원본 `data` 배열
@ -588,8 +599,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기
Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).**
해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
**[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
`materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) +
`mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을
순서대로 부르는 **두 줄짜리 공개 `attachSlot`**(이름/시그니처/호출부
전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다
먼저"가 처음으로 **동시에** 만족되고, 배치 밖 재마운트의 부모
`recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수
경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서"
나던 버그 클래스가 구조적으로 사라짐. 근거 기록은
`research/slot-attach-decomposition.md`.
**관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가
"부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의
`Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상