quad/.claude/session/2026-08-11-06-slot-single-nesting.md
qwreey 1f56c75978
chore(docs): split CLAUDE.md session log into .claude/session/, keep 2-4 line summaries
CLAUDE.md had grown to 3196 lines of accumulated session logs, causing
context bloat. Full session narratives (including trial-and-error and
later-corrected reasoning — quadnomicon devlog raw material) now live
as 39 individual files under .claude/session/. CLAUDE.md keeps only a
short "지금 할 일" (re-synced against question.md/pre-implementation-audit.md,
stale detail dropped) and a compact per-session summary+link table.
No design decisions changed; base/research/question.md were already
in sync with every session (verified against README.md/question.md
before archiving), so no unreflected content needed migrating first.
2026-08-11 14:40:43 +09:00

9.1 KiB

2026-08-11 여섯 번째 세션 — Slot:Single 확정, Slot-in-Slot 중첩 확정,

Length/Offset recompute off-by-one 버그 발견·수정

Slot():Single(state, updateFn?) 백로그(2026-08-09 여섯 번째 세션, "State<Frame?>가 offset을 못 받아서 위쪽 Slot의 offset/length를 써야 했다"는 동기)를 실제로 설계하다가, 더 큰 질문(Slot을 다른 Slot 안에 넣을 수 있는가)까지 라이브로 풀어낸 긴 세션. 다섯 갈래로 정리:

1. Slot:Single(state, updateFn):List 위의 순수 sugar로 확정. state를 0/1개짜리 배열로 감싸(:Compute) :List에 위임, 고정 key(true)로 prev 재사용을 보장, index는 상수라 안 넘김. 원래 동기(offset 접근)를 이걸로 완전히 해결 — "offset을 얻으려고 컴포넌트가 Slot을 리턴하는" 우회가 필요 없어짐. base/slot-plan.md "Slot:Single(...)" 절.

2. Slot-in-Slot 중첩 확정 — 동기는 카테고리 헤더가 아니라 컴포넌트 결합의 균일성. 사용자가 직접 짚은 진짜 이유: SomeComponent(props)Instance를 리턴하든 Slot(멀티루트 워크어라운드)을 리턴하든 outerSlot:Add(result)가 분기 없이 동작해야 함 — 지금까지 "요소 타입 제약"이 Slot을 암묵적으로 배제하고 있어서 정확히 이 케이스가 막혀 있었음. 핵심 발견 — 메커니즘은 그대로 재사용, 새 프리미티브 불필요: Dispatch.setLength/setOffsetSource의 첫 인자(inst)가 물리 Instance일 필요가 없다는 것(Relate가 아무 테이블이나 weak 키로 받음)을 재사용해, Slot 자신을 owner 키로 같은 두 함수를 한 번 더 부르면 최상위 마운트와 중첩 마운트가 완전히 같은 함수 호출이 됨 — 재귀 attachSlot(slot, physicalTarget, ownerKey, position) 하나로 통합. Slot.Length는 raw 개수에서 "요소별 기여도의 합"(plain=1, nested Slot=그 .Length)으로 의미 변경.

  • 타입 레벨로 확장하려던 "모든 instance 처리를 Slot에 위임"은 기각 — 리터럴 배열(Dispatch.drive)의 요소 타입 규칙(Ref/PreRef/Observer 허용)이 Slot의 요소 타입 규칙(같은 값들 금지)과 정반대라, 타입을 진짜로 통합하면 "만들어진 방식에 따라 행동이 다른 Slot"이라는 숨은 분기가 생김 — 메커니즘(setLength/setOffsetSource/recompute)만 공유하고 타입/CRUD 표면은 분리 유지로 스케일 확정(사용자 확인: "그게 더 엔지니어링 비용이 싸고 좋은 구현").
  • 파괴는 재귀적 Clear()가 아니라 flat destroySlotTree — 사용자가 직접 비용 문제 지적("clear된 다음 length 바뀌고 위치변경 전파되는 구조는 안 됨"): 재귀 Clear()(요소별 Remove 반복)는 죽는 서브트리 내부에서 불필요한 shift+recompute가 요소 수만큼 반복됨 — 대신 순수 파괴 walk(.Destroy()만)+unbindLifetime walk로 바꾸고, outer 쪽 recompute는 자기 위치 하나에 대해서만 1회. unbindLifetime이 왜 꼭 필요한지도 새로 드러남 — bindLifetime은 물리 target 생명주기에 걸려있어 target이 살아있는 채로 논리 서브트리만 죽는 경우(카테고리 삭제 등) GC가 자동으로 안 치워줌, 명시적 호출 필요(물리 target 자체가 죽는 경우는 기존처럼 GC가 전부 처리).
  • Slot(initial?: {T}) 생성자로 확장 — "인자 없는 빈 생성자로 확정"을 뒤집음(2026-08-09 세 번째 세션 결정 정정), 단 새 마운트 로직이 아니라 :Add 반복 호출 sugar(ipairs의 "첫 nil에서 멈춤" 동작이 "중간 nil UB, 그 뒤 무시"를 공짜로 구현). initial ~= nil이면 빈 테이블이어도 즉시 _crudUsed = true(사용자 지적: Slot({})은 상태상 add():remove(1)과 동일이라 결과가 비어있어도 "CRUD를 썼다"는 의도는 이미 커밋됨) — Slot()(진짜 nil)만 나중에 :List/:Single 설치 가능. _crudUsed_listed 상호 배타 가드도 신설 — 기존엔 :List 설치 후 수동 CRUD만 막았지 반대(수동 CRUD 후 :List 설치)는 안 막아서, :List의 reconcile이 기존 요소를 모른 채 충돌하는 gap이 있었음(사용자 발견).
  • DOM 백엔드가 nested Slot을 실제 <div> 중첩으로 매핑하는 안은 기각 — 제가 처음 낸 "web은 물리 nesting을 지원하니 이 메커니즘이 아예 필요 없을 수도"라는 제안을 사용자가 직접 반박: React <></>가 존재하는 이유와 정확히 같은 이유로 Slot도 의도적으로 wrapper 없는 그룹핑 도구라, div 매핑은 그 원칙 자체를 깨버림. 숫자 기반 메커니즘은 web에도 그대로 필요하되, insertBefore/removeChild가 물리적으로 밀고/당겨주므로 이미 배치된 형제 프로퍼티 재작성은 불필요(기존 2026-08-09 여섯 번째 세션 확정과 정합적, 사용자가 세션 도중 직접 재확인). "물리적으로 이전에 어디 있었는지" 같은 backend 종속 위치 정보는 base 책임이 아니라 필요한 backend가 자기 Relate로 저장할 몫 — 새 설계 불필요, 이미 확정된 base/backend 경계 그대로.

3. Dispatch.setLength/setOffsetSource/recompute의 owner 키가 물리 Instance로 한정될 필요 없다는 걸 base/bind-system-plan.md에 명시. "Length/Offset" 절에 짧은 절 신설 — 이게 위 재귀 메커니즘 전체의 근거.

4. recompute의 off-by-one 버그 발견·수정 — 중첩과 무관한, 기존 Length/Offset 메커니즘 자체의 버그. 구체 숫자로 흐름을 검증하려다 발견: 원래 코드가 sum += lengthList[i]를 먼저 하고 offset:Set(sum)을 나중에 해서, offset[i]가 "자기 앞의 형제들이 기여한 개수"가 아니라 자기 자신을 포함한 누적합이 되고 있었음(예: Frame{Slot1} 하나뿐이어도 Slot1.OffsetSlot1.Length가 되어버림) — 순서를 뒤집어(offset 먼저 Set, 그 다음 sum에 자기 기여도 누적) 수정. 지금까지 실제 Luau로 돌려본 적이 없어 아무도 못 잡았던 버그. 카테고리 헤더 예시(7개 리프)로 수정된 공식을 검증, 정확히 저작 순서대로 LayoutOrder 1..7이 나옴을 확인. offset/sum은 0-based 개수, index는 1-based Lua 관례라는 점도 명시적으로 콜아웃(둘을 섞는 index+offset 공식이 의도된 것이지 인덱싱 불일치가 아님).

5. Reentrancy 가드 검토 후 기각 — 제가 처음 제안한 _recomputing/ _dirty 플래그가 불필요함을 사용자가 정확히 캐치. "nested Slot이 있으면 항상 dirty가 켜져서 불필요한 for문이 한 번 더 돈다"는 사용자 지적을 계기로 호출 경로를 다시 추적 — 각 Slot이 Relate(자기 자신)으로 독립된 bk를 가지므로, 중첩 Slot의 Length 변경이 상위로 전파되는 경로는 항상 서로 다른 bk를 거쳐 지나감, 즉 nesting이 있다는 사실만으로는 같은 (ownerKey,bk)가 재진입되는 경로 자체가 없음이 확인됨 — 제가 가드의 동기 자체를 잘못 짚었던 것. 진짜 재진입(부작용이 recompute 도중 같은 Slot에 다시 Add/Remove)은 이미 확정된 "일반적 재진입/무한루프 방어 안 함, 사용자 코드 버그로 간주" 원칙 그대로 두면 되므로, 가드 없이 off-by-one만 고친 순수 버전으로 최종 확정. 같은 세션 후속으로 이 케이스에 명시적 이름을 붙임(사용자 제안)Source⊇State의 "단방향"(파생값이 자기 upstream Source로 거꾸로 안 쓴다) 원칙과 같은 카테고리 위반으로 프레이밍: recompute가 만드는 offset/LengthlengthList(upstream 입력)에서 파생된 다운스트림 값인데, 부작용이 자기 자신의 lengthList를 다시 mutate하는 게 그 반대 방향 쓰기라 "State가 자기 Source에 Set을 가하는 것"과 동일한 UB로 명명 — 새 원칙이 아니라 이미 있는 단방향 흐름 원칙의 재적용.

반영된 파일: base/slot-plan.md(요소 타입 제약/Slot(initial)/ CRUD 가드/Slot:Single/"Slot-in-Slot 중첩" 신규 절/Slot.Length), base/bind-system-plan.md(owner 키 일반화/recompute 버그 수정), ROADMAP.md(M6 체크박스 다수 추가), .claude/question.md(Slot:Single 백로그 해소 표시).

다음 세션이 할 일: 안 바뀜(ROADMAP.md M0부터, luau-test 결과 확인 우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. Slot<T>의 자기 참조 제네릭 실측이 luau-test 확인 목록에 새로 추가됨.