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>
77 KiB
구현 전 QA 4라운드 followup — 회신 처리 결과 + 재질문
상태: [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, H절에서 닫힘).
[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) 바로
반영한 것, (b) 설명이 부족해 다시 풀어 쓴 것, (c) 사용자 판단이 더 필요한 것,
(d) 조사해서 답이 나온 것으로 갈라 정리한 것이다.
같이 일어난 일 — 업스트림 스캐폴딩 병합. 문항지를 쓰는 동안 업스트림에
M0 스파이크 검증 + M1 스캐폴딩이 12커밋 올라와 있었고, 이 세션에서 pull 후
문항지 커밋을 그 위로 rebase했다. 그래서 문항지 일부가 이미 stale하다 —
어느 문항이 그런지는 아래 E절.
A. 반영 완료 — 바로 고친 것
전부 이번 세션에 base/(+HUMAN_TODO.md/question.md/todos.md)에
반영했고 doc-check.py ERROR 0을 유지했다. 회신에서 판단이 명확했던 것만
손댔고, 조금이라도 애매하면 아래 B/C로 뺐다.
| 항목 | 무엇을 고쳤나 | 대상 |
|---|---|---|
LP-1 |
Connected는 "계산된 속성"이 아니라 그냥 RBXScriptConnection의 네이티브 필드. 실제 판정은 "gcconn 없음" / "있는데 Connected==false" 두 상태뿐이고, quad가 참조를 nil로 끊는 자리는 없음. rbvm에서 가져오는 건 gcconn 관용구뿐이고 __index 계산 속성 구현은 안 가져옴 |
lifecycle-pattern.md |
LP-2 |
"예상보다 적을 수 있다"가 아니라 지금은 정확히 Effect 하나뿐 |
lifecycle-pattern.md |
LP-4 |
커스텀 Destroy-time 처리의 정상 경로는 [Event "Destroying"] 직접 바인드가 아니라 Effect(+슈가 OnDestroyed) |
lifecycle-pattern.md |
D-56 |
bindLifetime의 첫 인자가 물리 Instance가 아닐 수 있다(Slot-in-Slot의 ownerKey)를 백엔드 요구사항으로 신설. gcconn 트릭이 안 통하는 이유와, isBoundAlive에 세 번째 분기가 필요하다는 것까지 명시 |
lifecycle-pattern.md |
D-8/ML-9 |
Dispatch.listHandlers()는 Quad.debug와 무관하게 항상 호출 가능(목록을 반환만 하고 스스로 출력 안 함). 게이팅 대상은 "라이브러리가 스스로 콘솔에 쓰는" 동작뿐 |
dispatch-core-plan.md, module-lifecycle-plan.md |
D-60/SL-75 |
Slot.Offset은 마운트 전에도 nil이 아니라 0, 언마운트해도 nil로 되돌리지 않음. nil로 갈아치우면 그 Source를 이미 구독 중인 다운스트림이 영구히 끊겨 포탈이 깨짐 |
slot-plan.md, dispatch-core-plan.md |
SL-74 |
SetAndDispose는 source:SetAndDispose(value) 콜론 메서드로 확정(:Set과 한 세트). Apply 오버라이딩은 Source→State 단방향 때문에 타입이 안 성립해서 애초에 불가 → state:Apply 시그니처 영향 없음, 열린 항목에서 제거 |
slot-plan.md, question.md, todos.md |
SL-4 |
예시를 slot:Add(Frame { Ref = myRef }) → MyComponent { Ref = myRef } 로. Frame은 코퍼스에서 인스턴스 리터럴을 가리키는 이름이라 정반대 오해를 부름 |
slot-plan.md |
SL-72 |
조기 해제의 사용자 경로는 unbindLifetime이 아님(그건 Handler 작성자용 내부 배관) — State<Observer?>에 nil을 emit하는 게 정상 경로 |
slot-plan.md |
E-10 |
dedup 대칭 확인 완료 — relate로 이전 값을 들고 old ~= v / nextValue ~= v 두 분기 안에서만 bind/unbind(+내부 Observer cascade)가 일어나므로 성립. 남은 건 구현 시 회귀 확인뿐 |
effect-plan.md |
E-11 |
leaf 바인딩된 EffectHandle엔 :Unsubscribe()가 아예 안 먹는 것으로 Observer와 통일. 옛 "(3) 이후 leaf가 죽어도 중복 호출 안 됨"은 이중 바인딩 게이트상 성립할 수 없는 문장이라 삭제 |
effect-plan.md |
M-5 |
None만이 명시적 unsetter — mod:X(nil)은 "그 필드가 없는 새 Modifier", mod:X(None)은 "None으로 채워진 Modifier". 차이가 Overridden/Peek에서 관측된다는 것까지 예시로 |
modifier-plan.md |
M-9 |
"Getter는 안 만든다"와 :Peek의 관계 명시(전자는 필드별 setter 짝 getter, :Peek은 키를 받는 범용 접근자라 모순 아님) |
modifier-plan.md |
BK-9 |
HasBlocked는 "영원히 안 만듦"이 아니라 백로그 — 신설된다면 이름은 HasBlockedState 쪽 |
blocker-plan.md |
R-11 |
등록은 선형 탐색 함수가 아니라 그냥 table.insert(구멍을 알아서 되찾아 씀). 단 "#t가 항상 첫 nil 자리"는 Lua 명세상 보장이 아니므로 실측 스파이크 항목 추가 |
ref-plan.md |
AT-11 |
위치별 claim 레지스트리 이름 groupClaimKeys로 확정(키 설계는 여전히 미정) |
attribute-plan.md, question.md |
AT-20 |
생존 이름 최적화는 "부품이 늘어나서 안 하는" 게 아니라 원리적으로 불가능 — 이름이 같아도 값이 바뀌었을 수 있고, 값을 비교하려면 :Get()이 필요한데 그건 State 계약 위반 |
attribute-plan.md |
| Attribute 생성자 | Attribute(a, b, ...) 자신의 이름 겹침 정책이 어디에도 없었음 — Merged(error)/Overridden(뒤가 이김)만 적혀 있었다. 생성자는 뒤가 이김으로 명시 |
attribute-plan.md |
TW-16 |
initValue가 "에이전트 범위 밖"인데 HUMAN_TODO.md에 항목이 없던 것 — 10번으로 신설 |
HUMAN_TODO.md |
UI-5 |
해소 — process 위임을 하는 이상 gcconn/gchold 없으면 옵저버 바인딩부터 실패하므로 "조용히 미아"가 아니라 즉시 드러나는 전제 조건. ensureManagedChild가 일반 인스턴스 생성과 같은 경로를 타야 한다는 계약으로 승격 |
ui-shorthand-plan.md |
UI-8 |
mapTweenValue 로컬 헬퍼를 Tween<T>:Map(fn) 공개 메소드로 승격 |
tween-plan.md, ui-shorthand-plan.md |
UI-11 |
자식 파괴 시 retractFrom(child, prop, 1)은 "정석"이 아님 — 엔진이 Tween을 알아서 정리하고 PropertyHandler의 retractor는 애초에 no-op이라 두 겹으로 무의미 |
ui-shorthand-plan.md |
LH 백로그 |
진짜 componentDidMount(조상 체인까지 이어진 뒤)는 백로그로 신설 — 자기 위를 알아야 해서 drive 한 콜스택 안에서 표현 불가, 사용 사례가 나오면 재검토 |
lifecycle-hooks-plan.md |
D-3/D-10/LH-8 |
설명이 어렵다는 지적을 받아 코드/다이어그램으로 풀어 씀(아래 B절에 같은 설명 재수록) | 각 문서 |
부수로 확인한 것 — TW-12는 이미 맞게 적혀 있었음. 문항이
CanAnimate를 "단순 boolean"으로 축약한 게 문제였고, tween-plan.md는
처음부터 CanAnimate: State<boolean> | boolean | nil로 적어두고 resolve로
:Get()한다 — 문서 정정 불필요, 문항만 부정확했다.
B. 재질문 — 설명이 부족했던 것 (풀어 쓴 답 + 재확인)
전부 base 문서에도 같은 설명을 반영해뒀다. 아래 설명이 맞는지만 확인해주면 되고, 어긋나면 그 지점을 알려주면 문서까지 같이 고친다.
B-1. D-3 — "깊은 인덱스"가 뭔지, 그리고 철거 방향
질문: "방향이 그게 맞나? … 실제 process 에선 retract 가 인덱스 1 부터 5, 6, ... 순으로 작동함. 그런데 달라질 때 5, 4, 3, 2 ... 순이 되는건 아니지? '깊은 인덱스' 라는게 무슨 의미?"
답: "깊다" = 인덱스 숫자가 크다는 뜻이다(트리 깊이가 아니라 같은
(inst,k) 체인 안에서의 재귀 깊이). 그리고 "5,4,3,2 순이 되는 게
맞다."
(inst, k) 체인 — State<State<Tag>> 예시
index 1 : StoreBind ← 바깥 State 구독. "얕음"
index 2 : StoreBind ← 안쪽 State 구독(1이 재귀로 만든 것)
index 3 : TagHandler ← 최종 Tag를 실제 반영. "깊음"
- 설치(
Dispatch.process)는 1 → 2 → 3 — 사용자가 짚은 그대로. 각 레벨이 값을 한 겹 벗겨index + 1로 재귀하므로 인덱스가 커지는 방향. - 철거(
Dispatch.retractFrom)는 그 반대인 3 → 2 → 1 — 의사코드가for i = #list, index, -1로 꼬리부터 역순으로 돈다. - 왜 반대여야 하나: index 2가 index 3을 만들어낸 주체다. 만든 쪽을 먼저 지우면 만들어진 쪽을 정리할 주체가 사라진다 — 스택을 쌓은 역순으로 푸는 것(LIFO)과 같은 이유. 그래서 각 retractor는 자기가 만든 하위 인덱스를 쫓아갈 필요가 없다(자기 차례엔 아래가 이미 비어 있음).
- 주의: 이건 한
(inst,k)체인 안의 이야기다. 서로 다른 키는 완전히 별개 배열이고 서로의 순서와 무관하다.
→ 이 이해가 맞나?
B-2. D-10 — Luau 일반화 for는 이미 지원하는데 뭘 검증한 건가
질문: "루아우의 일반화된 반복 for 이 이를 그냥 지원함. 어떻게 실제로 이해했는지 확인 필요"
답: 맞다 — Luau가 배열 파트를 먼저 훑는다는 걸 의심한 적은 없다. 문항이 "M0에서 순회 동작을 검증한다"고 적어 마치 언어 동작을 의심하는 것처럼 읽힌 게 문제였다. 실제 구분은 이렇다:
- 언어가 주는 것: 일반화
for가 배열 → 해시 순으로 돈다. 2026-08-07에 사용자가 REPL로 직접 확인한 관찰. - 그런데도 드라이버가 두 패스를 명시하는 이유는 순서를 못 믿어서가 아니라 (1) 이식성 — 다른 백엔드가 props를 Lua 테이블이 아닌 자료구조로 표현하면 그 근거 자체가 없어짐, (2) 어차피 숫자 키/문자열 키를 다른 의미로 처리해야 해서 구분 비용이 이미 들고, 순서 고정이 거의 공짜.
- 스파이크
01이 검증한 것: 언어 동작이 아니라 "우리가 짠 두 패스 드라이버가 계약대로 도는가". 그 스파이크 주석도 "사용자가 이미 REPL로 확인했었지만(우연한 관찰), base는 이 우연한 동작에 기대지 않고 … 명시적으로 강제하기로 확정함"이라고 같은 구분을 적어두고 있다. - [업스트림 반영] 이미 통과했다 —
luau-test/done/01-two-pass-array-hash-order.luau. 문항의 "M0에서 검증할 항목이다"는 stale이라 base 문서에서 닫았다. nil-hole로 배열 파트가 통째로 해시 취급이 되는 문제는 이것과 별개이고02/06스파이크가 담당한다.
→ 이 구분이 맞나?
B-3. D-32 — "재위임 핸들러는 (A) 분기에서도 반드시 다시 재위임"이 무슨 말인가
질문: "무슨말인지 확인 필요"
답: **"조건부로만 재위임하는 핸들러를 새로 만들면 고아 체인이 생긴다"**는 경고다. 지금 존재하는 핸들러엔 위반 사례가 없어서 추상적으로만 적혀 있었다.
가상의 위반 예시로 설명하면:
-- ⚠️ 이런 핸들러를 새로 만들면 위험하다는 뜻
function MaybeWrapHandler.process(inst, k, v, index)
if v.enabled then
Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함
end
-- enabled가 false면 아무것도 안 함 ← 여기가 문제
return function() end
end
- 1차 사이클에
v.enabled == true→ index 2에 하위 체인이 설치됨. - 2차 사이클에 같은 핸들러로
v.enabled == false가 오면 (A) 분기를 탄다 (핸들러가 같으므로retractFrom이 안 불림 — (A)는 아래를 안 건드리는 게 핵심). - 그런데 이번엔 재위임을 안 했으므로 index 2에 옛 하위 체인이 그대로 남는다. 아무도 그걸 지우지 않고, 옛 값에 대한 구독/부작용이 계속 산다.
- 해법: 재위임을 건너뛰는 그 자리에서
Dispatch.retractFrom(inst, k, index + 1)을 직접 불러 아래를 비운다.
StoreBind/NoneHandler는 항상 재위임하므로 이 함정에 안 걸린다 —
그래서 "지금 위반 사례는 없다"고 적혀 있는 것이다.
→ 이 설명이 의도한 내용이 맞나? (맞다면 base 문서에도 이 예시를 넣겠다 — 지금은 규칙만 있고 예시가 없어서 읽고 이해가 안 되는 게 정상이다.)
B-4. BR-7 — duck-typing 기각 근거를 더
질문: "더 설명좀 필요"
답: Brand 대신 type(x) == "table" and x.Compute ~= nil 같은 모양으로
판별하지 않는 이유가 두 가지다.
- false positive —
Modifier필드나:Peek가 돌려주는T는 사용자가 넣은 임의의 값이다. 사용자가 우연히Compute라는 필드를 가진 테이블을 넣으면 quad가 그걸State로 오인한다. 브랜드는 quad가 만든 값에만 찍히므로 이 오인이 원천적으로 없다. - 인덱싱 자체가 터질 수 있음 — 일부 Roblox userdata는 정의되지 않은 키를
인덱싱하는 것만으로 에러를 던진다(
x.Compute를 읽는 순간 throw). 그래서 duck-typing을 하려면 판별 코드를 전부pcall로 감싸야 하고, 그건 "판별은 부작용 없이 빠르게"라는isHandlable계약(base/dispatch-core-plan.md)과 정면으로 부딪힌다. 최악의 경우엔 엔진 자체가 죽는 상황도 있다.
즉 (1)은 정확성 문제, (2)는 안전성+비용 문제라 서로 독립된 두 근거다.
weak-key 레지스트리 조회는 포인터 해싱 한 번이라 pcall도, 오인도 없다.
→ 이 두 근거로 충분한가? 빠진 게 있나?
B-5. SL-5 — _mounted 트리거 시점을 더 풀어서
질문: "더 풀어 서술해주길 바람. 판단 보류"
답: 요지는 **"_mounted를 언제 true로 세우는가"**이고, 후보가 둘
있었다.
-- 후보 A (채택): Dispatch가 이 Slot을 실제로 처리하기 시작한 시점
function SlotHandler.process(inst, k, slotValue, index)
...
attachSlot(slotValue, inst, inst, k) -- 이 안에서 _mounted = true
end
-- 후보 B (기각): 물리 Parent 대입이 끝난 시점
element.Parent = physicalTarget
slot._mounted = true -- ← 여기서 세우는 모양
- 왜 A인가: quad의 다른 "마운트됐다" 판정이 전부 dispatch-process
시점 기준이다 —
PreRef가 소진되는 시점,Ref콜백이 fire되는 시점,claimOwnerAt이 소유권을 잡는 시점이 전부 그렇다. 여기 하나만 "물리 Parent 대입 이후"로 두면 같은 사이클 안에서 어떤 판정은 이미 마운트됨, 어떤 판정은 아직 아님인 구간이 생긴다. - 오탐 걱정이 없는 이유: "컴포넌트가 Slot을 prop으로 받아 저장만 하고
실제 트리에 안 놓는" 경로에서는
Dispatch.process가 애초에 안 불린다 — A 기준으로도_mounted가 안 켜진다. - ⚠️ 단, 이 서술은 그 뒤에 한 번 더 정밀해졌다(
SL-55) — 지금attachSlot은_mounted = true를 함수 맨 위가 아니라activateList뒤에 둔다.activateList가 도는 동안_mounted가false여야 reconcile의rawAdd가 "아직 마운트 전"(=_elements에만 넣고 끝) 경로를 타기 때문. 즉 "process가 불린 순간"이 아니라 "process가 불려 들어간attachSlot안에서,activateList가 끝난 직후"가 정확한 시점이다. 원칙(dispatch-process 시점 기준, 물리 Parent 기준 아님)은 그대로.
→ 이 정리가 맞나? 특히 마지막 ⚠️(정확한 시점이 activateList 직후)까지
포함해서 확인 부탁.
B-6. SL-59 — 재귀적 Clear() 금지가 어떤 상황인가
질문: "실 상황에 대한 설명 더 필요함. 이 글만 보아서는 어떤 상황인지 정확히 판단 어려워보임. 판단 보류."
답: 중첩 Slot을 파괴할 때 "각 요소를 Remove로 하나씩 지우는" 순진한
구현을 하면 O(n²)가 된다는 얘기다.
outer(position 3에 마운트됨)
└ inner Slot (요소 500개)
outer가 파괴될 때 두 가지 방식:
- 순진한 방식(금지) —
inner:Clear()가 요소마다rawRemove를 부른다.rawRemove하나당 (a)spliceArraysDown으로 뒤 배열을 한 칸씩 당기고 (b)recompute(inner, bk)로 전체 순회를 돈다. 500개면 당기기 500번 × 평균 250칸 + recompute 500번 × 500칸. 어차피 통째로 죽을 서브트리인데 그 안에서 위치 계산을 500번 다시 하는 셈. - 채택한 방식 —
destroySlotTree는 부기를 전혀 안 건드리고 요소를 훑으며 파괴만 한다(spliceArraysDown도,recompute도 안 부름). 어차피 이 Slot 자체가 사라지므로 그 안의 위치 계산은 의미가 없다. 바깥에서 딱 한 번 —outer가 차지하던 position 3에 대해setOffsetSource(None)→setLength(0)을 부르고, 그때recompute가 한 번 돈다.
즉 "재귀적 Clear() 금지"는 API를 금지한다는 게 아니라, 파괴 경로가
CRUD를 재사용하면 안 된다는 구현 지침이다.
→ 이 이해가 맞나?
B-7. SL-63 — recompute가 owner의 Length를 직접 Set하는 게 이상하다
질문: "(b) 가 뭔가 이상함. 오너의 length 를 직접 설정하지는 않을것임. 자신 length 를 변경하면, 자동으로 observer 에 등록된것으로 인해 length 가 업데이트 되는 방식일텐데 … 최종 리컴퓨팅 결과가 length 가 되는거 아니였음?"
답: 사용자 이해가 맞고, 문항의 표현이 나빴다. "owner"라는 말이 두 가지로 읽혀서 생긴 혼동이다.
local function recompute(ownerKey, bk)
...
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum)
end
end
- 여기서
ownerKey는 부모가 아니라, 지금 자식들의 합을 구하고 있는 그 Slot 자신이다. 즉recompute(innerSlot, bk)가innerSlot.Length를 세운다 — "최종 리컴퓨팅 결과가 자기 Length가 된다"는 사용자 이해 그대로. - 부모는 이 값을 직접 안 받는다 — 부모는
attachSlot에서Dispatch.setLength(부모ownerKey, position, innerSlot.Length)로 그 State 객체 자체를 등록해뒀고, 값이 바뀌면 Observer로 통지받아 자기recompute를 돈다. - 그래서 실제 흐름은
innerSlot.Length:Set(sum)→ 부모의 observer 발화 →recompute(부모, ...)→ 부모의 뒤 형제 offset 갱신, 이고 "부모 length를 직접 설정"하는 코드는 어디에도 없다.
문항(그리고 base 문서의 "(b) owner가 Slot이면 그 .Length")이 ownerKey를
그냥 "owner"로 줄여 써서 부모로 읽히게 만든 게 원인이다.
→ 이 정리가 맞나? 맞다면 base 문서의 그 표현도 "그 recompute의 ownerKey
자신"으로 명확히 고치겠다(아직 안 고쳤음 — 확인 후 반영 예정).
B-8. SL-76 — recompute가 nil을 관대하게 넘기는 게 왜 필요한가
질문: "애초에 해제에서 nil이 관측 될 일이 없다고 생각하는데, 그게 아니라면 다시 더 자세히 말을 해주길 바람."
답: 지금 설계에서 도달 가능한 경로를 저도 못 찾았다. 그 방어 조항은
2026-08-13(bk.N 정의가 확정되기 전, Blocker 게이팅이 생기기 전)에 들어간
것이고, 그 뒤 두 변경으로 전제가 바뀌었다:
bk.N이 "그때그때 실제 개수"로 확정돼(2026-08-18),lengthList/sourceList가 아직 안 채워진 위치를recompute가 읽을 일 자체가 없어짐.- 배치 등록 중엔 Blocker 게이팅으로
recompute가 아예 안 돎. - 해제는
setOffsetSource(None)→setLength(0)이라None이지nil이 아님. spliceArraysDown은 배열을 압축하므로 중간에 구멍을 안 남김.
→ 그래서 선택지 셋 중 하나를 골라야 한다(사용자 판단 필요, C절 C-6으로
옮김). 지금은 문서를 안 고치고 그대로 뒀다.
B-9. SL-78 — nested Slot의 Length만큼 건너뛰는 얘기가 뭘 섞고 있나
질문: "애초에 slot 내의 index 와, length 로 구해진 offset 은 다른 개념인데, 너무 섞어 말하는것 아닌지 … Length 업데이트는 상위 slot 이 observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것임?"
답: 섞어 말한 게 맞다. 두 얘기를 분리하면 이렇다.
- 형제 Slot 사이의 offset — 사용자 말대로 자동이고 이 캐비엇과
무관하다. nested Slot의
Length가 바뀌면 부모가 observe해서recompute가 돌고 뒤 형제 offset이 갱신된다. 여기엔 문제가 없다. - 같은
:List안에서updateFn에 넘어가는index— 이쪽이 캐비엇의 대상이다.reconcile은pos를 훑으며 각 아이템의index를 계산하는데, 어떤 아이템이 nested Slot(Length=3)을 반환하면 다음 아이템의index는 1이 아니라 3만큼 건너뛴다. 그런데index는 그 사이클의 raw 스냅샷이라,:List가 다시 reconcile되지 않은 채 그 nested Slot의 Length가 3→5로 바뀌면 뒤 아이템들의index는 3 기준으로 멈춰 있다.
즉 캐비엇은 offset이 아니라 index에 대한 것이고, "형제 Slot 갱신"이
아니라 **"한 :List 안 아이템들끼리"**의 얘기다. 그리고 이건 이미 확정된
"index는 State가 아니라 raw number"의 당연한 연장이라 새 문제가 아니다 —
실시간 정확성이 필요하면 updateFn이 result.Length를 직접 구독해야 한다.
→ 이 분리가 맞나? 맞다면 base 문서의 그 캐비엇 문단도 "offset이 아니라
index 얘기"임이 드러나게 고치겠다.
B-10. LH-8 — "자기 아래 vs 자기 위"를 그림으로
질문: "사람이 너무 이해하기 어려운 표현임 … 풀어 보여줘야함."
답: Frame { Frame { TextLabel {}, OnRendered(fn) } }에서 안쪽 Frame의
fn이 불릴 때:
바깥 Frame { ... } ← ⑤ 이 호출은 아직 시작도 안 함
└ 안쪽 Frame { ... } ← ④ 이 drive가 지금 끝나는 중
├ TextLabel {} ← ① 이미 완성돼 안쪽 Frame에 붙어 있음
└ OnRendered(fn) ← ③ fn이 여기서 불림
(프로퍼티/이벤트) ← ② 이미 전부 세팅됨
- ①② = "자기 아래" — 자식(과 그 서브트리 전체) + 자기 프로퍼티/이벤트.
fn이 불릴 때 전부 끝나 있다. - ⑤ = "자기 위" — 안쪽
Frame이 바깥Frame의 자식이 되는 일. Lua 표현식 평가 순서상 안쪽Frame{...}호출이 끝나야 바깥Frame의 props 테이블이 완성되므로,fn이 불릴 때 바깥Frame은 존재하지도 않는다. - 그래서 "화면에 올라간 뒤"가 아니다 — 화면에 올라가려면 루트까지 이어져야 하는데 그건 ⑤ 이후 일이다.
- 원래 뭐가 헷갈렸나: 경계를 "이 인스턴스의 프로퍼티만이냐(a) / 자식 서브트리까지냐(b)"로 놓았는데, 배열 파트 루프가 각 자식을 동기적으로 끝내고 넘어가므로 ①이 공짜로 따라온다 — (a)/(b)는 갈리는 지점이 아니었고 실제로 갈리는 건 ①②(자기 아래)와 ⑤(자기 위)였다.
→ 이 그림이 맞나? (base 문서에도 이 그림을 넣어뒀다.)
C. 사용자 판단 필요 — 임의로 처리하지 않은 것
전부 아직 문서를 안 고쳤다. 판단이 갈리거나 파급이 커서 임의 결정이 위험한 것들이다.
C-1. ⭐ SL-40/SL-43/SL-45 — KeyGone 센티널 신설
사용자 제안: 키가 데이터에서 사라지면 updateFn을 KeyGone으로 한 번 더
불러 처분을 묻고(T | KeyGone), userdata를 지울지도 사용자가 정하게 위임.
이걸로 SL-45(Detach 홀드 중 키 소멸)가 닫힌다.
방향은 좋아 보인다 — SL-45가 "파괴도 반환도 안 되고 참조만 끊긴다"는
어정쩡한 상태였던 근본 원인이 "키가 사라지는 순간엔 updateFn에게 물어볼
방법이 없다"였고, KeyGone이 정확히 그 구멍을 메운다. 다만 정하지 않으면
구현이 못 나가는 세부가 넷 있다:
updateFn이KeyGone을 받았을 때 반환값의 의미는? 제안:nil→ 파괴(기본),Detach→ 언마운트만(사용자가ud로 홀드),prev그대로 반환 → ? (키가 없는데 계속 마운트해두는 건 모순으로 보임 — error가 맞나, 아니면 파괴로 취급하나?)userdata엔트리는 언제 지워지나? "유저가 결정"이면updateFn이(result, nil)을 반환할 때만 지운다는 뜻인데, 그러면 사용자가ud를 계속 반환하는 한userdata[key]엔트리가 영원히 남는다(키는 이미 사라졌으므로 다시 물어볼 기회도 없음). 의도된 것인가, 아니면 "한 번 물어본 뒤엔 무조건 지운다"인가?- 다음 사이클의 소멸 루프가 무엇을 순회하나? 지금은 직전
keyIndex를 순회하는데,ud만 남은 키는keyIndex에 없다.userdata키까지 합집합으로 순회해야 하나(그러면 매 사이클 그 키를 계속KeyGone으로 다시 물어보게 됨), 아니면 한 번 물어본 키는 목록에서 빼나? index/offset인자는 뭘 넘기나? 사라진 키엔 위치가 없다.nil? 마지막으로 알던 값? (updateFn시그니처가index: number로 확정돼 있어서nil을 넘기면 타입이 바뀐다.)
추가로 이름/배치: Detach와 같은 급의 sentinel이므로 패키지 최상위
export가 일관적이다(None/Detach 선례). 이름은 KeyGone이 의미는
명확한데, 코퍼스가 "이 자리에서 무엇을 하라"는 지시형(None=세팅 안 함,
Detach=떼되 죽이지 마라)을 쓰는 것과 달리 KeyGone은 상태 서술형이라
결이 조금 다르다 — 그게 오히려 맞을 수도 있다(이건 지시가 아니라 통지이므로).
그대로 갈지 확인 부탁.
C-2. ⭐⭐ SL-43 vs SL-51 — "밀려난 prev는 dispose"와 state<Frame> 의미론이 충돌한다
이번 회신에서 나온 것 중 파급이 가장 크다. 두 답변이 서로 반대 방향을 가리킨다:
SL-43답변: "'updateFn이 새 값을 반환하면 밀려난 prev는 언마운트만' 는 이상한듯. 새 값으로 밀려난 prev 는 dispose 되는게 맞음. updateFn 은 직접 destroy 를 호출 못함 … 그래서 지울 방법이 존재하지 않고, 지워주는게 맞다고 봄."SL-51답변: "state -> slot {frame} 형태가 될 때 이전 state 에서 변경으로 다른게 와도, slot 이 이전 frame 을 destroy 해버리면 안 됨."
둘 다 맞는데 서로 다른 경우다 — 갈리는 축은 누가 그 요소를 만들었는가:
| 경로 | 요소를 만든 주체 | 밀려난 prev의 올바른 처분 |
|---|---|---|
:List(data, updateFn) — updateFn이 Frame{...}을 만들어 반환 |
updateFn |
dispose — 만든 쪽이 자기 손으로 못 지우니(reconcile 중이라 dispose가 거부됨) reconcile이 대신 지워야 함 |
Slot:Add(state) 라 sugar(:Single + identity updateFn) |
사용자(state에 담아 넘김) | 언마운트만 — state<Frame>가 이전 값을 안 죽인다는 확정 의미론 그대로 |
지금 설계는 후자를 표현할 방법이 없다. Detach는 "이 자리를 비우되 죽이지
마라"라서 교체와 같이 못 쓴다(새 값을 반환하는 순간 result ~= nil 경로로
가고, 그 경로가 dispose가 됨).
선택지 셋:
- (a)
:List/:Single에 소유권 옵션을 둔다 — 예:Slot:Single(state, updateFn?, opts?)의opts.Owned = false. 설치 시점에 한 번 정하고,Slot:Add(state)sugar가Owned = false로 설치한다. 장점: 갈리는 축(누가 만들었나)이 실제로 설치 단위 속성이라 의미가 정확하고, 매 사이클 반환값에 부담을 안 준다. 단점: 옵션 파라미터가 하나 늘고, "한:List안에서 어떤 아이템은 내가 만들고 어떤 건 사용자 것"인 혼합은 표현 못 함(그런 사례가 있는지는 모르겠음). - (b)
Detach를 호출 가능하게 만들어Detach(newValue)로 — "교체하되 옛 것은 떼기만" 을 반환값 프로토콜 안에서 표현. 장점: 국소적이고 per-cycle로 정확. 단점: 2026-08-19에 확정한Detach배치 결정과 부딪힌다 — 그때 "sentinel 상수 하나 때문에 callable-table+메타테이블 구조를 들이는 건 과함"이라고 판단해 최상위 순수 sentinel로 뒀다. - (c) 새 sentinel 하나 더 — 예: 반환값을
(newValue, ud, DetachPrev)처럼 세 번째 슬롯으로 신호. 단점: 반환 프로토콜이 복잡해짐.
추천: (a). 갈리는 기준이 per-cycle이 아니라 per-installation이라는 게 분석의 핵심이고, (b)는 방금 내린 결정을 뒤집는 비용이 있다.
같이 확인 부탁: SL-51에서 "list 슈거에서 Detach 가 사용중인지 확인이
필요해보임" 이라 하셨는데 — 지금 :Single의 기본 identity updateFn은
Detach를 전혀 안 쓴다(그냥 item을 반환). 그래서 위 충돌이 실재한다.
C-3. M-2 — flatten이 소진한 Modifier 자리에 구멍이 남는다 (⭐ 실제 갭)
사용자 제안: "이것도 이 이후 ProcessedModifier / ProcessedModifierHandler 를 만들면 될듯. Post/Pre Ref 와 유사히 처리 가능하다고 생각함."
조사 결과 — 이건 문서의 실제 갭이 맞다. modifier-plan.md는 flatten이
"isModifier(v)가 참인 항목만 필드를 뽑아 merge하고 나머지는 안 건드린다"고만
적어두고, 뽑아낸 그 자리를 어떻게 하는지를 한 번도 안 적었다. 그냥 지우면
배열에 구멍이 생기고, 그건 PreRef pre-pass가 ProcessedPreRef로 소진하는
바로 그 이유(sparse 테이블이 되면 배열 파트 전체가 순서 보장을 잃음)에 정확히
걸린다.
선택지 둘:
- (a)
ProcessedModifier센티널 + 전담 nop Handler —Pre/PostRef와 완전히 대칭. 그 Handler가setOffsetSource(None)/setLength(0)을 등록하니 Length/Offset 계약도 특수 취급 없이 만족된다. 일관성 최고. - (b) flatten이 결과 배열을 압축(compact) — flatten은 이미
flatten(nonFlatten) -> flattened라는 새 테이블을 만드는 순수 변환이라, Modifier 자리를 아예 안 넣고 뒤를 당겨 담으면 구멍 자체가 안 생긴다. 센티널도 Handler도 불필요. 단, 이러면 배열 위치가 원본과 달라진다 —flattened만 보는drive입장에선 무해해 보이지만, 확인이 필요하다.
추천: (b) — flatten이 어차피 새 테이블을 만드는 이상 가장 부품이 적다.
Pre/PostRef가 센티널을 써야 했던 이유는 pre-pass가 flattened를
제자리에서 소진하고 그 뒤 두 패스가 같은 테이블을 다시 돌기 때문인데,
flatten은 그 앞 단계라 그 제약이 없다. 다만 사용자가 (a)를 제안하신 만큼
어느 쪽인지 정해주면 그대로 반영하겠다.
C-4. destroySlotTree가 자식 소유권을 명시적으로 반납해야 하는가
사용자 의문: "Destroy 된 요소는 다른곳에 원래 마운트 못하는게 보통 엔진 정상이고, 또, 릴리즈 안 되어 다른곳에 마운트 막혀도 상관 없고, 그게 정상 동작일 수 있어보임."
분석 — 사용자 쪽이 더 맞아 보인다. 명시적 releaseOwner가 들어간 원래
근거(2026-08-13 감사)는 *"elementOwner가 weak라 GC 타이밍에 따라 '이미
마운트돼 있음' error가 비결정적으로 터진다"*였는데, 다시 보면:
- 그 error가 나는 상황 자체가 버그다 — 파괴된 요소를 다른 곳에 다시 넣으려는 코드이기 때문. 즉 "비결정적으로 실패"의 반대는 "성공"이 아니라 **"항상 실패"**이고, 그게 더 나은 동작이다.
- 명시적 반납은 오히려 그 버그를 통과시킨다 — 파괴된 요소를 재마운트하는 코드가 조용히 성공하고, 그 다음에 죽은 Instance를 다루다 엉뚱한 데서 터진다.
- 비결정성만 없애면 된다 — 반납을 안 하면 GC 전엔 error, GC 후엔 통과라 여전히 비결정적이다. 이걸 결정적으로 만들려면 오히려 파괴된 요소에 "파괴됨" 표식을 남기는 쪽이 맞는데, 그건 새 부기다.
선택지:
- (a) 명시적
releaseOwner제거 — 단순해지고, "파괴된 걸 재사용하면 (대개) 막힌다"는 동작이 남는다. 비결정성은 그대로. - (b) 유지 — 지금 문서 그대로.
- (c) 반납 대신 "파괴됨" 마킹 — 결정적으로 항상 error. 부기가 하나 는다.
추천: (a), 다만 "비결정적"이라는 성질이 남는 걸 문서에 명시. (c)는
conventions.md의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 구조를
복잡하게 만들지 않는다" 기준으로 지금은 과해 보인다.
C-5. SL-58 — 배치 밖 재마운트의 낭비가 실제로 있나 (+ 개선안)
사용자 질문: "offset 이 전부 같아 set 안 일어나고 가벼운거로 아는데, 아님?"
분석 — 뒤에 형제가 없으면 맞고, 있으면 아니다.
steady state에서 position k의 state<Slot>이 교체될 때:
attachSlot이setOffsetSource(ownerKey, k, ...)→1..k-1합이라 안 바뀜(사용자 말대로 여기까진 무해).Dispatch.setLength(ownerKey, k, slot.Length)→slot.Length는 아직 flush 전이라 0. Observer "등록 즉시 1회 실행" → 부모 blocker가 꺼져 있으니recompute(부모)가 즉시 돈다.- 그
recompute는lengthList[k] = 0으로 계산하므로k+1..N위치의 offset이 전부 줄어든 값으로:Set된다 → 그 아래LayoutOrder캐스케이드. - flush가 끝나고
slot.Length:Set(최종)→ 다시recompute→ offset 원복.
즉 k 뒤에 형제가 하나도 없으면 낭비가 정말 없고(offset이 바뀔 대상이
없음), 뒤에 형제가 있으면 그 형제들의 offset이 두 번 Set되고 다운스트림도
두 번 돈다. 값은 결국 맞으므로 크래시나 영구 오류는 아니다.
개선안(제안): attachSlot에서 Dispatch.setLength를 flush 루프
뒤로 옮기면 이 왕복이 사라진다 — 그 시점엔 slot.Length가 이미 최종
값이라 부모 recompute가 한 번만 돈다. 배치 안에서는 어차피 부모 blocker가
켜져 있어 차이가 없고, 배치 밖에서만 이득이다.
- 확인 필요한 것:
setOffsetSource→setLength순서 계약은 유지된다 (둘 다 flush 앞뒤로 갈릴 뿐 상대 순서는 그대로).bk.N을setLength가 올리는 규칙도 한 position 안에서는 영향이 없어 보인다. - 판단 부탁: 이건 확정된 의사코드의 순서를 바꾸는 것이라 임의로 안 고쳤다. 옮길지, 지금처럼 두고 "한 프레임 낭비 허용"으로 남길지.
C-6. SL-76 — recompute의 nil 관대 처리를 어떻게 할지
B-8의 조사 결과, 지금 설계에서 도달 경로를 못 찾았다. 선택지:
- (a) 유지 + 근거 갱신 — "지금은 도달 경로가 확인되지 않지만 전이 구간 방어로 남긴다"로 문구만 정직하게 고침.
- (b) 삭제 —
None만 처리하고nil은bk.N계약 위반이므로 자연히 터지게 둠. - (c)
error로 승격 —nil이 관측되면 부기가 깨졌다는 신호이므로 조용히 skip하지 말고 즉시 error(코퍼스의 "매치 실패는 즉시 error",releaseOwner불일치 error와 같은 톤).
추천: (c) — 관대한 skip은 "위치 하나가 조용히 순서 계산에서 빠지는" 디버깅 어려운 오작동이 되고, 지금은 그게 정상 경로로 생기지 않는다는 게 분석 결과이므로 error가 더 안전하다.
C-7. "Length로 먼저 밀어내고 그 공간에 넣는다"가 다른 CRUD/:List에도 통하는가
사용자 제기: "만약 밀어내고 당기지 않은 상태에서 그 공간에 넣는다 하면, 밀어내는걸 구현해야하는 백엔드에서 골치아파짐. 지금 어떤 상황인지 확인해볼것"
조사 결과 — rawAdd만 순서가 명시돼 있고 나머지는 안 적혀 있다.
rawAdd(문서화됨):self.Length:Set(newCount)(→ 뒤 형제 offset 갱신이 여기서 동기적으로 끝남) →element.Parent = target. 즉 "밀어낸 뒤 넣는다".rawRemove/rawUnmount(문서화 안 됨): 의사코드는unbindLifetime→releaseOwner→ 파괴/언마운트 →spliceArraysDown→recompute순서다. 즉 "빼고 나서 당긴다".rawAdd의 거울상이라 일관돼 보이지만 명시적으로 계약화돼 있지는 않다.Splice(문서화 안 됨): "shift+recompute 1회로 묶는다"만 있고 물리 detach/attach와 부기의 선후가 안 적혀 있다. 제거분과 삽입분이 겹치는 구간이라 특히 애매하다.:List의rawMove(문서화 안 됨): "Parent를 안 건드린다"만 확정돼 있어 물리 이동이 없으니 이 문제에서 빠지는 것으로 보인다.
판단 부탁: 이걸 일반 계약으로 승격할지 — 예컨대 "부기(Length/offset)
갱신이 물리 트리 조작보다 항상 먼저 끝난다" 를 dispatch-core-plan.md나
slot-plan.md에 한 줄로 못박고 모든 raw*가 따르게 할지. 그러면 백엔드
작성자가 "내가 물리적으로 밀어낼 때 부기는 이미 정확하다"를 전제할 수 있다.
승격에 찬성하시면 문구를 써서 반영하겠다.
C-8. SL-48 — Slot-in-Slot에서도 Destroy 후 정리가 성립하나
사용자 메모: "더 나아가 slot in slot 에서도 유효한가 생각해보아야함. 아마 그런것으로 알고있음. 피지컬 홀더랑 오너가 다르거든."
분석 — 성립하지만, D-56이 해결돼야 성립한다. 중첩에서 둘이 갈린다:
- physicalTarget(물리 Instance) —
attachSlot이 자식 요소를 실제로Parent하는 대상. 최상위든 중첩이든 같은 물리 부모다. - ownerKey(부기 소유자) — 최상위는
inst, 중첩은 부모 Slot 자신.
Destroy 시나리오를 따라가면:
- physicalTarget이 Destroy → gcconn 끊김 → 그
inst에bindLifetime된 것들은canExecute가 거짓이 되어 발화가 멈춘다. ✅ - 중첩 Slot의
setLengthObserver는bindLifetime(ownerKey=부모Slot, observer)로 묶여 있다 —inst가 아니다. 그래서 1번만으론 안 끊기고, 부모 Slot이 unreachable해질 때 같이 죽어야 한다. - 부모 Slot은
gchold(physicalTarget의)에 강참조로 매달려 있으므로, physicalTarget이 GC되면 부모 Slot도 참조가 끊긴다 → 그 아래Relate(부모 Slot)부기도 weak-keyed라 같이 사라진다. ✅
결론: 체인이 성립한다. 다만 2번이 성립하려면 bindLifetime이 Slot을
첫 인자로 받는 걸 실제로 처리해야 한다 — 그게 A절에서 신설한 D-56
요구사항이다. 즉 SL-48은 "이미 맞다"가 아니라 D-56이 구현되면 맞다.
→ 이 분석이 맞나? 특히 3번(physicalTarget → 부모 Slot → 중첩 부기의 연쇄 GC)이 실제로 그렇게 도는지.
C-9. AT-13 — retractor를 부르는 주체
사용자 확인 요청: "정확히는 같은 핸들러 재프로세스는 retractor 를 process 에서 굴리고 자기 작업을 함. 따라서 process → calls retractor(v) → process new one 이 맞는걸로 보이는데 … 실제 구현은 저렇게 알고 있는게 맞음?"
답: 맞다. 의사코드 그대로다:
function Dispatch.process(inst, k, v, index) -- ← 오케스트레이터
...
if slot ~= nil and slot.handler == h then
slot.retractor(v) -- ① 여기서 굴림
slot.retractor = NOOP
local retractor = h.process(inst, k, v, index) -- ② 자기 작업
...
즉 Dispatch.process(오케스트레이터)가 ①과 ②를 둘 다 부른다. 문항의
"retractor(v) → process"는 그 둘의 순서만 적고 누가 부르는지를
생략한 축약이었고, 사용자 이해가 정확하다. handler.process(개별 핸들러)는
자기가 retractor를 부르지 않는다 — 그건 오케스트레이터의 일이다.
→ 문서엔 이미 이렇게 적혀 있어 정정 불필요. 확인만.
D. 문항지 자체가 stale해진 것 (업스트림 병합 결과)
문항지는 pull 전 base/를 기준으로 썼다. 업스트림 12커밋이 들어오면서
아래가 이미 달라졌다 — 문항이 틀린 게 아니라 시점이 지났다.
| 문항 | 무엇이 달라졌나 |
|---|---|
ML-5 |
사용자가 회신에서 직접 짚음("새로운 커밋에서 이것이 달라짐"). 파일마다 Relate()+INITED 센티널을 두는 설계가 RunInit 하나로 통합됐다 — 함수 자기 자신을 릴레이션 키로 쓰므로 센티널이 불필요. quad-base/src/init.luau에 실제 구현 + test/smoke.init.luau로 검증까지 끝. 문항 ML-5는 폐기. |
ML-8 |
위 변경에 맞춰 RunInit(함수 identity) vs _initializedBy(문자열 마커) 구분으로 재서술됨. 결론은 그대로(둘은 다른 층위, 재사용 안 함)이라 문항 자체는 여전히 유효. |
ST-3 |
"M0에서 실측 확인할 것"이 실측 완료됨 — luau-test/done/21-type-store-undeclared-key-rejected.luau가 미선언 키 접근 2건이 정확히 TypeError로 거부됨을 확인. ⚠️ 열린 항목이 아니게 됨. |
D-10 |
위 B-2 — 스파이크 01이 이미 통과. |
A-2 |
패키징이 wally → pesde로 바뀌었고 mise/selene가 도입됨. 문항의 "wally 패키지" 표현이 stale(모놀리식 모노레포라는 결론 자체는 유지). |
| 전반 | M0/M1이 완료됐다 — 문항지 여기저기의 "M0 착수 전/M0에서 확인" 표현은 이제 "M2 착수 전"으로 읽어야 맞는 것들이 섞여 있다. |
신규 문서 2개는 문항이 아예 없다 — base/project-setup-plan.md(pesde/mise/
selene/Rojo/darklua 경계)와 base/quad-types-plan.md(AddPlugin<Self,P>/
CheckedQuad<T, Pattern>/type-version-check). architecture.md와
typing-limits.md도 이 라운드에 내용이 늘었다. 원하시면 이 넷만 대상으로
5라운드 문항지를 따로 만들겠다 — 지금 4라운드 회신 처리가 먼저라 착수하지
않았다.
E. 회신 방법
- B절(재질문) — 각 항목의 "이 이해가 맞나?"에만 답해주면 된다. 맞으면 그대로 두고, 어긋나면 그 지점만 알려주면 base까지 같이 고친다.
- C절(판단 필요) — 선택지에 번호를 달아뒀으니 고르거나, 더 나은 안을
주면 된다.
C-1(KeyGone)과C-2(dispose vsstate<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 자신도 별도로
들고 있어야 한다. 이유 셋:
userdata는:List에게 opaque하다 — 계약상 "안을 전혀 안 들여다본다" 이므로, 정리 시점에 뭘 죽여야 하는지 알 수가 없다.- 소유권이 Slot에 남아야 한다 —
elementOwner가 여전히 이 Slot을 가리켜야 detached 요소를 다른 곳에 못 붙인다(안 그러면 "떼어놨는데 남이 가져감"). - 파괴 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 뿐임" 과도 일치).
-- 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에 옵션 하나.
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)의 이유로 안 죽는다.
그래서 필요한 것(위 제안들의 귀결):
slot._detached를 필드로 두고destroySlotTree가_elements다음에 그것도 훑어 파괴.unmountSlotTree는_detached를 안 건드린다(언마운트는 파괴가 아니고, 재마운트 시 그대로 이어져야 함).Owned = false면destroySlotTree도 파괴 대신 언마운트.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는 루프를 두 번 돈다:
-- 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/PostRefpre-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 스케치를 그대로 반영하되 반복 방향만 역순으로 고쳤다. 이유:
if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = 먼저 쓴 쪽이 이김
- 정방향(
for i = 1, #input)이면 배열 앞쪽 modifier가 먼저 써서 이긴다. - 그런데 확정된 규칙은
modifier-plan.md2번의 **"배열 순서상 나중 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 액티베이션 → 3setLength→ 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가 정리되면 같이).
G. 3차 회신 처리 (2026-08-21)
G-1. 반영 완료
| 항목 | 회신 | 반영 |
|---|---|---|
F-3 (4) |
확인 — "unowned 로 나오는 경우가 state<Frame> 등을 주는 경우이므로 설치 시점이다에 동의함" | slot-plan.md에 "소유권은 설치 시점에 정해진다 — Owned 옵션" 절 신설. Owned=true(기본)/false 대조표, Detach와 직교하는 축이라는 것, destroySlotTree/dispose도 이 플래그를 봐야 하므로 클로저가 아닌 Slot 필드(slot._owned)여야 한다는 것까지 |
F-4-1 |
맞음 — "__pairs/__ipairs 직접 구현체를 담은 ud 등을 받는 flattened 는 없고, luau 테이블만 사용하는게 맞음" |
dispatch-core-plan.md의 "props 순회 순서" 절을 **"계약은 순서 보장, 구현은 일반화 for 한 번"**으로 정정. 옛 근거 (1)("다른 백엔드가 props를 Lua 테이블이 아닌 자료구조로")이 inst엔 해당해도 flattened엔 해당 안 됨을 명시. 스파이크 01은 두 루프 버전이라 재작성 필요로 STATUS.md에 표시 |
F-4-2 |
확인 | 이미 반영돼 있던 역순 정정 유지(modifier-plan.md) |
G-2. F-4-3 → 확장 논의 자료 준비 완료
회신: "리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 attachSlot 되는게 맞을지도. attachSlot 의 기능이 너무 다양해진게 문제같음. 이 부분에 있어서는 확장 논의를 하게 준비해두자."
→ research/slot-attach-decomposition.md 신설. setLength를 어느 줄에
둘지 고르는 문제가 아니라 분해 문제라는 진단에 동의하고, 논의가 바로 시작될 수
있게 재료만 모아뒀다(아무것도 확정 안 함, base/slot-plan.md가 여전히 정본):
- 책임 일곱(R1~R7) — 부모 등록(offset)/
:List실체화/마운트 상태 전이/ 부모 등록(length)/배치 게이팅/자식 배치/재귀. 서로 다른 축 넷이 섞여 있음. - 순서 제약 일곱(C1~C7)과 그 출처 — 전부 실제로 밟은 버그에서 나온
것이라(
RC-1/RC-3/RC-4, 해제 순서 계약,C-7일반 계약) 분해안이 하나라도 깨면 그 버그가 되돌아온다는 걸 표로. - ⭐ C6 ↔ C7 충돌이 근본 — "부모에게 알리는 길이의 최종값은 flush가
끝나야 정해짐"(C6)과 "부기가 물리보다 먼저"(C7)는 단일 함수 안에서 R4의
자리가 하나뿐이라 동시 만족이 불가능하다. 그래서
F-4-3이 자리 선택으로는 안 풀렸던 것. - 분해 후보 넷 — (A) 현행 유지 / (B)
prepare+mount2단(회신의 "액티베이션 먼저 → length 얻어 밀고 → attach"를 구조화한 것, R6를 부기와 물리로 쪼개면 C6·C7을 둘 다 만족) / (C) 3단(+Dispatch.drive까지 같은 모양으로 수렴) / (D) 문서만. - 같이 정해야 하는 것 6가지 —
activateList의 ObserverbindLifetime이 어느 단계인지, 얇은attachSlot래퍼를 남길지,Dispatch.drive도 맞출지, prepare만 하고 mount 안 한 중간 상태 처리,_mounted소비처가 새 정의로도 맞는지, 그리고Detach정리용Effect를 어디에 설치할지. - 순서 권고: 마지막 항목 때문에
F-3이 먼저 닫히는 게 낫다 — 그 결정이 이 분해의 요구사항을 하나 더 얹는다.
G-3. 아직 확인 안 된 것 — F-3의 나머지
F-3은 (4)번만 확인을 받았다. (1)~(3)과 "+"(dispose 재귀)는 아직
답이 없어서 base에 아무것도 안 넣었다. 요지만 다시 줄이면:
- detached 요소가 영구 누수인 이유 — gcconn 트릭 때문에 quad-제작
Instance는 자기 시그널 커넥션이 자기를 살린다("참조를 놓는 것만으로는
회수되지 않고 반드시
Destroy로 회수된다",lifecycle-pattern.md) → GC 폴백이 아예 없다. 명시적 정리 경로가 필수. - 그래서 detached는
userdata가 아니라slot._detached필드가 들고 있어야 한다 —userdata는:List에게 opaque라 뭘 죽여야 할지 모르고, 소유권이 Slot에 남아야 남이 못 가져가며,destroySlotTree의 walk가 닿아야 한다. 부수 이득으로 다음 사이클에prev로 그대로 돌려줄 수 있어ud홀드가 불필요해진다. - owner 죽음 처리는
Effect(제안하신 그대로), 단 소유 층위는activateList가 아니라attachSlot/unmountSlotTree쌍 — Slot이 다른physicalTarget에 재마운트되면 Effect도 옮겨야 하고, 안 그러면 옛 target이 죽을 때 살아있는 Slot의 detached를 파괴한다. KeyGone후 "다시 안 묻기"는 자동 성립 — 소멸 루프가keyIndex만 도니까 사라진 키는 다음 사이클 대상이 아니다. 남은 미결은KeyGone에prev를 그대로 반환하면?(→error추천)과index인자(→0추천).- "+"
dispose재귀 — 중첩 Slot 재귀는 이미 된다. 안 되는 건_detached(walk가 안 닿음)와userdata안의 quad-제작 Instance. 후자는SL-38의 "GC만으로 정리되는 값만" 제약에 "quad Instance는 GC로 안 죽는다"를 예시로 추가해야 한다(지금은:Subscribe()Observer만 예시라 Instance는 안전해 보인다).
→ 여기 동의가 나오면 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) → BlockerOn→ 자식 재귀/setLength→OffWithoutEmit→recompute→ 마지막에setLength(ownerKey, position, slot.Length).mountSlotTree(slot, physicalTarget)— 물리Parent대입과_mounted = true,_detachCleanupEffect 설치만. 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.md3번의 관련 항목은 이 처리로 전부 닫혔다.- 사용자 지시대로 5라운드 문항지는 만들지 않는다 — "이후 stale 만 잡는 것으로 끝낼 수 있어보임"(D절 회신).
- 실측으로 남은 것: 스파이크
01재작성(단일 generalizedfor),table.insert구멍 재사용(R-11) 스파이크. 상태의 소스는luau-test/STATUS.md.