quad/.claude/research/slot-attach-decomposition.md
qwreey 1e6cb0111d
design: QA 4라운드 3차 회신 반영 + attachSlot 분해 논의 자료 준비
확인받은 것 반영:
- Owned 설치 플래그 확정 — "누가 요소를 만들었는가"는 사이클마다 달라지는 게
  아니라 설치 시점 속성이라는 사용자 판단. Detach(사이클 판단)와 직교하는
  축이므로 반환값 계열에 unowned 센티널을 더 만들지 않는다. destroySlotTree/
  dispose도 이 플래그를 봐야 해서 클로저가 아닌 Slot 필드여야 함.
- props 순회를 일반화 for 한 번으로 정정 — flattened는 항상 평범한 Luau
  테이블(__pairs/__ipairs를 갈아끼운 ud가 들어올 경로가 없음)이라 옛 근거
  "다른 백엔드가 Lua 테이블이 아닌 자료구조로"는 inst엔 해당해도 flattened엔
  해당하지 않았다. 계약(배열 먼저)은 그대로, 구현만 1회 순회로. 스파이크 01은
  두 루프 버전이라 재작성 필요로 STATUS에 표시.

attachSlot 분해는 research/slot-attach-decomposition.md로 준비:
setLength를 flush 앞/뒤 어디에 둘지가 안 풀린 이유가 자리 선택이 아니라
"한 함수가 책임 일곱을 지고 있어서"라는 사용자 진단에 따라, 책임 목록과
순서 제약(전부 RC-1/RC-3/RC-4 등 실제로 밟은 버그가 출처)을 모으고 C6(길이
최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는
동시 만족 불가능함을 보인 뒤 분해 후보 넷을 대조. 확정은 아무것도 안 함.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 11:10:42 +09:00

11 KiB
Raw Blame History

attachSlot 책임 분해 — 확장 논의 준비 자료

상태: research — 논의 전 준비 자료. 아무것도 확정하지 않았고, 다음 논의가 바로 시작될 수 있게 지금 무엇이 얽혀 있는지를 한 곳에 모은 것.

왜 생겼나: 2026-08-21 구현 전 QA 4라운드 F-4-3에서 Dispatch.setLength를 flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가 아니라고 짚었다 — "리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 attachSlot 되는게 맞을지도. attachSlot 의 기능이 너무 다양해진게 문제같음. 이 부분에 있어서는 확장 논의를 하게 준비해두자."

정본은 여전히 base/slot-plan.md의 "재귀 메커니즘" 절 — 이 문서가 그 확정을 대체하지 않는다.


1. attachSlot이 지금 하는 일 — 책임 일곱 개

base/slot-plan.md의 의사코드를 책임 단위로 쪼개면 이렇다(코드는 그 문서가 소스, 여기선 라벨만 붙임):

라벨 하는 일 층위
R1 offsetSource 생성 → Dispatch.setOffsetSource(ownerKey, position, ...)slot.Offset 공개 부모에 대한 자기 등록
R2 slot._listedactivateList:List reconcile이 _elements를 채움(물리 마운트 없음) 내용 실체화
R3 slot._mounted = true, slot._mountedInst = physicalTarget 자기 상태 전이
R4 Dispatch.setLength(ownerKey, position, slot.Length) 부모에 대한 자기 등록
R5 getBlocker(slot):On():OffWithoutEmit() + 마지막 recompute 배치 게이팅
R6 flush 루프 — 각 요소의 부기 등록(setOffsetSource/setLength) + 물리 마운트(Parent 대입) 자식 배치
R7 중첩 Slot 요소에 대해 attachSlot 재귀 재귀

서로 다른 축이 넷 섞여 있다 — (a) 부모에게 나를 알리는 일(R1/R4), (b) 내 내용을 만드는 일(R2), (c) 내 자식을 실제로 붙이는 일(R6), (d) 상태 플래그와 배치 게이팅(R3/R5). 사용자가 "기능이 너무 다양해졌다"고 한 게 이것.

호출부는 셋:

  • SlotHandler.process(최상위) — bindLifetimeattachSlot(slotValue, inst, inst, k)
  • R7(중첩) — flush 루프 안에서 재귀
  • rawAdd(런타임 단건) — 이미 마운트된 Slot에 나중에 nested Slot을 Add할 때

2. 순서 제약과 그 출처 — 왜 지금 모양이 됐나

이 제약들은 전부 실제로 밟은 버그에서 나왔다. 분해안을 평가할 때 하나라도 깨면 그 버그가 되돌아온다.

# 제약 출처
C1 slot.Offset(R1)이 activateList(R2)보다 먼저 :ListupdateFnoffset을 인자로 받는 계약 slot-plan.md :List 파라미터
C2 activateList(R2)는 _mounted == false인 상태에서 돌아야 함 → R2가 R3보다 먼저 아니면 reconcile의 rawAdd가 매 항목마다 즉시 물리 마운트 + setLength를 태워 (a) Blocker 없이 recompute가 돌고 (b) nested Slot에 attachSlot두 번 불림 RC-3/RC-4(QA 3라운드)
C3 attachSlot반환될 때는 _mounted == true여야 함 런타임 rawAdd가 이 플래그로 "지금 붙일까 _elements에만 넣을까"를 가름 slot-plan.md rawAdd
C4 setOffsetSourcesetLength보다 먼저 setLength 끝의 gatedRecompute가 죽는 중인 Source에 :Set을 날림 dispatch-core-plan.md 해제 순서 계약
C5 R5의 Blocker가 R6 전체를 감쌈 없으면 등록마다 recompute가 돌아 O(N²) RC-1 해결(QA 2라운드)
C6 R4가 넘기는 slot.Length최종값은 R5/R7이 끝나야 정해짐 중첩 Slot 요소의 .Length는 그 요소의 attachSlot이 돌아야 확정됨 구조적
C7 부기 갱신이 물리 트리 조작보다 먼저 백엔드가 "내가 물리적으로 밀어낼 때 부기는 이미 정확하다"를 전제할 수 있어야 함 dispatch-core-plan.md 일반 계약(QA 4라운드 C-7)

C6와 C7이 정면으로 부딪힌다 — 이게 F-4-3의 근본

  • C7을 지키려면 R4(부모에게 내 길이 알리기)가 R6(자식 물리 마운트)보다 먼저여야 한다.
  • C6를 지키려면 R4는 R6/R7이 끝난 여야 최종값을 알 수 있다.

지금은 C7을 지키고 C6를 포기했다 — R4가 slot.Length(값이 아직 0인 State 객체)를 넘기고, flush가 끝나 recompute가 실제 값을 넣으면 부모가 Observer로 다시 반응해 스스로 교정한다. 대가는 배치 밖 재마운트에서 부모 recompute가 2회 도는 것(뒤에 형제가 있으면 그 offset들이 두 번 Set됨).

단일 함수로는 둘 다 만족할 수 없다 — 한 함수 안에서 R4의 자리가 하나뿐이기 때문. 그래서 이건 "어느 줄에 놓을까"가 아니라 분해 문제다.


3. 분해 후보

(A) 현행 유지 — 단일 attachSlot

  • C7 지킴, C6 포기(자기 교정 1회).
  • 비용: 배치 밖 재마운트마다 부모 recompute 1회 낭비. 크래시도 영구 오류도 아님(SL-58에 이미 "손대지 않기로" 기록됨).
  • 문제: 사용자가 지적한 "기능이 너무 다양함"은 그대로 남는다.

(B) 2단 분리 — prepare / mount 사용자 제안에 가장 가까움

"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 attachSlot 되는게 맞을지도" 를 그대로 구조화하면 이 모양이 된다. 핵심은 R6를 부기(R6a)와 물리(R6b)로 쪼개는 것.

-- 개념 스케치. 이름/시그니처 전부 가칭
local function prepareSlot(slot, physicalTarget, ownerKey, position)
    -- R1
    local offsetSource = Source(0)
    Dispatch.setOffsetSource(ownerKey, position, offsetSource)
    slot.Offset = offsetSource

    -- R2 (여전히 _mounted == false — C2)
    if slot._listed then activateList(slot, physicalTarget) end

    -- R5 + R6a + R7-prepare : 부기만, 물리 마운트 없음
    local blocker = getBlocker(slot)
    blocker:On()
    for i, element in ipairs(slot._elements) do
        if isSlot(element) then
            prepareSlot(element, physicalTarget, slot, i)   -- 재귀 → element.Length 확정
            Dispatch.setLength(slot, i, element.Length)
        else
            Dispatch.setOffsetSource(slot, i, None)
            Dispatch.setLength(slot, i, 1)
        end
    end
    blocker:OffWithoutEmit()
    recompute(slot, bk)          -- 여기서 slot.Length가 **최종값**으로 확정 (C6 만족)
end

local function mountSlot(slot, physicalTarget)
    slot._mounted = true                       -- R3 (C3)
    slot._mountedInst = physicalTarget
    for i, element in ipairs(slot._elements) do
        if isSlot(element) then mountSlot(element, physicalTarget)   -- R7-mount
        else element.Parent = physicalTarget end                      -- R6b
    end
end

-- 호출부(최상위)
prepareSlot(slotValue, inst, inst, k)
Dispatch.setLength(inst, k, slotValue.Length)   -- R4 — 이제 최종값 (C6 + C7 동시 만족)
mountSlot(slotValue, inst)
  • C6와 C7을 둘 다 만족한다 — 부모에게 넘기는 길이가 처음부터 최종값이고, 그 등록이 어떤 Parent 대입보다도 먼저다.
  • 각 함수의 일이 하나로 좁아진다prepareSlot = "부기를 정확하게 만든다", mountSlot = "그 부기대로 트리에 붙인다".
  • _mounted의 의미가 정직해진다 — 지금은 "activateList는 지났고 flush는 아직"이라는 어정쩡한 중간 시점인데, 분리하면 문자 그대로 "mount 단계를 지났는가"가 된다.
  • 비용: _elements 순회가 2회로 늘고, 호출부가 셋 다 두 함수를 순서대로 불러야 한다(빠뜨리면 half-attached 상태).

(C) 3단 분리 — register / activate / mount

R1(부모 등록)까지 따로 떼는 안. Dispatch.drive의 최상위 배열 파트도 같은 3단으로 맞추면 "배치 등록 → 실체화 → 마운트"라는 하나의 모양이 코퍼스 전체에 반복된다.

  • 이득: Dispatch.driveattachSlot이 지금 서로 비슷한데 미묘하게 다른 구조(전자는 Blocker + 두 패스, 후자는 Blocker + flush)인 걸 하나로 수렴시킬 수 있음.
  • 비용: 단계가 하나 더 늘고, 호출부가 셋 → 셋 × 3단이 됨. (B)의 이득 대부분을 (B)만으로 이미 얻으므로 추가 이득이 뭔지가 논의 대상.

(D) 최소 변경 — 분리 없이 문서만

attachSlot 안을 R1R7 주석 블록으로 명시하고 각 제약(C1C7)을 그 자리에 달아둠. 코드는 그대로.

  • 이득: 위험 0. 비용: 근본 문제(C6/C7 충돌, 책임 과다)는 안 풀림.

4. 어떤 분해든 같이 정해야 하는 것

  1. activateListdata:Observer(fn) bindLifetime은 어느 단계인가. 지금은 activateList 안에서 bindLifetime(inst, observer)를 부르는데, (B)에서 그건 prepare 단계다 — 아직 아무것도 물리적으로 안 붙은 시점에 physicalTarget에 생명주기를 묶는 게 맞는지.
  2. 호출부를 감싸는 얇은 attachSlot을 남길지. 남기면 SlotHandler.process/ rawAdd가 지금처럼 한 줄로 끝나고 "한쪽만 부르는" 오용도 막힌다. 대신 "결국 다시 한 함수"라 분해의 이득이 반쯤 희석된다.
  3. Dispatch.drive도 같은 모양으로 맞출지(후보 (C)와 직결).
  4. prepare만 하고 mount 안 한 중간 상태를 어떻게 다룰지 — 방어할지, UB로 둘지. 코퍼스 기조상 UB + 문서화가 자연스러워 보이지만 확인 필요.
  5. _mounted 소비처가 새 정의로도 맞는지 — (a) :List()가 마운트 이후에 불릴 때 즉시 activateList하는 분기, (b) rawAdd의 "붙일까 말까" 분기. (B)에서는 _mounted가 mount 단계에서 켜지므로, prepare와 mount 사이에 :List()가 불리는 경로가 있는지 따져야 한다.
  6. Detach 정리용 Effect(QA 4라운드 F-3)를 어디에 설치할지 — 그건 physicalTarget에 묶이므로 mount 단계가 자연스럽다. F-3이 먼저 닫히는 게 순서상 낫다 — 그 결정이 이 분해의 요구사항을 하나 더 얹는다.

5. 지금 상태 요약

  • 확정된 건 없다. base/slot-plan.md의 단일 attachSlot이 여전히 정본.
  • 급하지 않다 — (A)로 두어도 동작은 맞고(자기 교정), M2/M3 착수를 막지 않는다. 다만 M6(:List) 구현 전에는 정해두는 게 낫다 — 그 시점에 activateList/rawAdd/attachSlot을 실제로 짜기 때문.
  • 선행 항목: 위 4-6번대로 F-3(Detach 보관 위치 + KeyGone)이 먼저 닫히면 이 논의의 요구사항이 완전해진다.