# [역전됨] 재디스패치 = "철거 후 재구축" + `hintValue` 힌트 모델 **상태**: 역전됨 — 2026-08-13 여섯 번째 세션에 사용자가 결함을 제기하고 방향을 제시, 같은 날 열네 번째 세션에 마지막 열린 항목(Attribute 이름 소유권)까지 확정되며 **`base/dispatch-core-plan.md`로 전면 반영 완료**. 이 문서는 그때까지 research/ 아래 dispatch-redispatch-diff-plan이었던 설계안 원문이고, **지금 유효한 모델은 `base/dispatch-core-plan.md`의 "Dispatch 체인" 절** — 여기 남기는 이유는 "왜 힌트 모델을 버렸는가"의 재현 사례와 근거가 통째로 보존될 가치가 있어서(같은 함정을 다시 설계하지 않도록). ## 뒤집힌 옛 모델의 골자 (원문 보존) ```lua -- 옛 계약: 래핑 핸들러가 재-dispatch 전에 자기 아래를 먼저 철거하고, -- 그때 "곧 디스패치될 raw 값"을 힌트로 실어 보냄. function StoreBind.process(inst, k, state, index) local observer = state:Observer(function() local realv = state:Get() Dispatch.retractFrom(inst, k, index + 1, realv) -- 선행 철거 + 힌트 Dispatch.process(inst, k, realv, index + 1) end) ... end function Dispatch.retractFrom(inst, k, index, v) -- 4-인자(마지막이 힌트) ... retractor(if i == index then v else nil) -- 힌트는 직속 1단계에만 ... end ``` 옛 모델이 같이 요구하던 규칙들(전부 지금은 폐기): - **`isX(hintValue)` 가드 필수** — 힌트의 타입이 계약으로 보장되지 않아서. - **`hintValue`는 직속 위임 1단계에만 보장, 깊은 인덱스는 항상 `nil`** — 그래서 `State>`류에서 깜빡임 방지가 조용히 꺼짐. - **`Dispatch.process`의 점유 체크(이미 점유된 인덱스면 즉시 error)** — Attribute 이름 소유권이 이 부수 효과에 얹혀 있었음. ## 아래는 역전을 이끈 분석 원문 (당시 `research/` 문서 그대로) ## 1. 현행 `hintValue`의 실제 결함 (사용자 지적, 확인됨) 현행 계약: `Dispatch.retractFrom(inst,k,index,v)`가 `index` 자리 retractor에 `v`를 넘김. 그 `v`는 **"그 자리에 곧 디스패치될 raw 값"**이다. **문제: 그 raw 값이 핸들러가 이해하는 의미 값이라는 보장이 전혀 없음.** ### 1-1. 힌트로 `None` 센티널이 그대로 넘어감 (사용자 제기, 재현됨) `[AttributeKey "foo"] = state`, state가 `5` ↔ `None`을 오갈 때: | 사이클 | 체인 | |---|---| | `None` | `StoreBind@1` → `NoneHandler@2` → `AttributeKeyHandler@3` | | `5` | `StoreBind@1` → `AttributeKeyHandler@2` | `5` → `None`으로 갈 때 StoreBind는 `retractFrom(inst,k,2,None)`을 부르고, 인덱스 2의 `AttributeKeyHandler` retractor가 **`hintValue = None`** 을 받음. Attribute는 클로저가 no-op이라 무해하지만, `TagHandler`였다면 `isTag(None)`이 거짓이라 "이 자리가 Tag이길 그만둔다"로 오판해 이름 전부를 `RemoveTag`함. ### 1-2. 래핑이 한 겹만 끼어도 힌트가 무의미해짐 같은 이유로 힌트가 `State`, `Tween`, 그 외 미래에 추가될 어떤 래퍼여도 말단 핸들러는 그걸 해석 못 함. 앞서 문서화했던 "깊이 2 이상에선 `nil`" 문제는 이 결함의 **한 특수 케이스**였을 뿐 — 진짜 문제는 깊이가 아니라 **"힌트의 타입이 계약으로 정해져 있지 않다"**는 것. ### 1-3. 그래서 힌트 기반 최적화가 "가끔 조용히 꺼진다" `Tag`의 `Contains` skip, `Ref`/`Slot`의 identity 비교는 전부 힌트가 자기 타입일 때만 동작 — 위 경로들에선 소리 없이 전량 정리로 퇴화함. **정확성은 유지되지만(그래서 지금까지 안 드러남) 깜빡임/재생성 방지라는 존재 이유가 무너짐.** 특히 `Slot`은 "가드가 없으면 마운트된 서브트리 전체가 **[정정, 2026-08-13 4차 감사]** 언마운트됐다 재마운트"(작성 당시 아직 destroy 모델 — 파괴가 아니라는 것만 정정, 파급이 크다는 결론은 그대로)라 파급이 큼. ## 2. 채택 모델 — 재디스패치는 "철거 후 재구축"이 아니라 "하강 diff" 사용자 정리: > "차라리 process 를 쭉 진행해, 이전거랑 자신 것이랑 다르면 nil 또는, > 그걸 나타내는 HandlerChanged 등의 무언가로 아래를 전부 죽이고, 같으면 > 값을 계속 넣어가며 전파해야할듯, 같은것이면 본인 인덱스에 대해서만 > 후처리를 해." > > "retract 계약이 지금 보면, 일단 시도해보는데, 이전이 내 핸들러면 내가 > 처리, 아니고 다르면 아래쪽을 그냥 retract 처리해서 전부 제거" 즉 **래핑 핸들러가 재-dispatch 전에 `retractFrom`을 먼저 때리는 것을 폐기**하고, 그냥 `Dispatch.process`를 아래로 내려보냄. 비교는 `Dispatch.process` 안에서 일어남: ```lua -- chains[inst][k][index] = { handler = h, retractor = fn } function Dispatch.process(inst, k, v, index) local list = <확보 + chains에 등록> -- 기존 순서 규칙 그대로(h.process 前) local slot = list[index] local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존대로 즉시 error if slot ~= nil and slot.handler == h then -- 같은 핸들러: 아래를 안 건드리고, 이 자리 클로저에 새 값을 넘겨 -- 스스로 전이를 처리하게 함. v는 h.isHandlable(v)가 참임이 보장됨. slot.retractor(v) slot.retractor = h.process(inst, k, v, index) else -- 다른 핸들러(또는 빈 자리): 이 자리부터 아래를 전부 철거 후 새로 설치 Dispatch.retractFrom(inst, k, index, nil) list[index] = { handler = h, retractor = h.process(inst, k, v, index) } end end ``` `StoreBind`/`NoneHandler`는 이제 그냥: ```lua Dispatch.process(inst, k, realv, index + 1) -- retractFrom 선행 호출 없음 ``` ### 2-1. 이걸로 동시에 풀리는 것들 - **힌트의 타입이 보장됨** — 클로저에 값이 넘어가는 건 **오직 핸들러가 같을 때뿐**이고, "같다"는 건 `getHandler(inst,k,v) == slot.handler` 로 판정한 것이므로 `v`는 정의상 그 핸들러의 `isHandlable`을 만족함. 1-1/1-2의 `None`/래퍼 오염이 **구조적으로 불가능**해짐. 지금의 "`isX(hintValue)` 가드 필수" 일반 규칙 자체가 힌트의 타입 미보장을 메우던 임시방편이었음이 드러남 — 그 규칙도 같이 없앨 수 있음. - **깊은 체인의 힌트 유실도 사라짐** — 힌트를 위에서 아래로 전파하는 게 아니라, **각 레벨이 자기 재프로세스에서 자기 힌트를 받음**. `State>`에서 바깥이 새 inner State를 내놓아도: index 2는 StoreBind끼리 같으니 자기 클로저가 구독을 갈아타고, 재위임으로 내려간 index 3은 TagHandler끼리 같으니 **진짜 `Tag` 객체를 힌트로 받아** `Contains` skip이 정상 동작. (앞 라운드에 "구조상 불가피"라고 적었던 건 틀렸음 — 철거 선행 모델에서만 불가피했던 것.) - **`StoreBind` 구독 갈아타기 문제 없음**(사용자 지적: "다만 뭐가 문제인지 모르겠어요") — "같은 핸들러면 **유지**"가 아니라 "같은 핸들러면 **자기 클로저 → 자기 process**"라, 옛 구독 해제와 새 구독이 그 안에서 끝남. 검토 중 제가 문제로 짚었던 것은 "유지"로 잘못 읽은 데서 나온 것. - **최상위는 애초에 안 돌아감** — 값이 안 바뀌면 Observer가 안 뛰므로 재프로세스 자체가 없음. ### 2-2. 두 종류의 retract가 계약상 갈림 (사용자 정리) > "새 프로세싱으로 인한 retract처리와, 단순 retract는 다르다" - **단순 retract**(언마운트/전체 철거, `retractFrom`): 뒤따르는 `process`가 없음. 힌트는 항상 `nil`. 핸들러는 자기 기여를 무조건 전부 걷어냄. - **재프로세싱**: 핸들러가 같으면 그 자리 클로저가 **새 값을 힌트로** 받고, 다르면 그 자리부터 아래가 단순 retract 됨. ### 2-3. 전제 계약 — `inst` 부작용은 말단 핸들러만 (사용자 명시) > "inst 에 실질적 처리를 가하는 동작은 항상 말단 핸들러 노드다" > "중간 노드는 단순 언워랩만 한다" **기존 핸들러 전부가 이미 만족함**(확인함): | 핸들러 | 위치 | `inst` 부작용 | |---|---|---| | `StoreBind` | 중간 | 없음(구독 + 재위임만) | | `NoneHandler` | 중간 | 없음(재위임만) | | `PropertyHandler` | 말단 | 프로퍼티 세팅 | | `TagHandler` | 말단 | `AddTag`/`RemoveTag` | | `AttributeKeyHandler` | 말단 | `SetAttribute` | | `SlotHandler` | 말단 | 마운트 | | `RefLeafHandler` | 말단 | `Ref:Set` | | `UICornerHandler` | 말단 | 자식 Instance 생성/제거 | | `AttributeGroupHandler` | 자기 체인에선 말단 | 없음(다른 키로 위임) | 새 제약을 거는 게 아니라 **이미 성립하는 성질을 계약으로 승격**하는 것. ## 3. 검토 중 정리된 것 ### 3-1. `HandlerChanged` 마커는 불필요 "핸들러가 바뀜"은 **그 자리 retractor가 `nil` 힌트로 불린다는 사실 자체**로 이미 표현됨. 별도 마커 값을 만들면 그것도 결국 "힌트로 넘어오는 정체불명의 값"이 되어 1-1과 같은 문제를 되풀이함. ### 3-2. `oldValue`를 넘기자는 보완안 — 철회(사용자 지적) > "이전 값인 oldValue 는 처음부터 클로저라 이미 본인이 알지 않아요?" 맞음. 클로저는 자기 `process` 호출의 `v`를 upvalue로 캡처하고 있고, 힌트로 새 값을 받으므로 **old/new를 이미 둘 다 갖고 있음.** 문제였던 건 오직 힌트의 타입 보장이고, 그건 2-1의 핸들러 선비교로 해결됨 — `chains`에 값을 따로 저장할 이유가 없음. (`chains`에 추가로 저장해야 하는 건 **비교용 `handler` 하나뿐**.) ### 3-3. 중간(재위임) 핸들러에 붙는 작은 계약 같은 핸들러로 재프로세스될 때, **재위임하는 핸들러는 반드시 다시 재위임해야 함.** 안 그러면 아래 인덱스가 고아로 남음(아무도 안 지움). `StoreBind`/`NoneHandler`는 항상 재위임하므로 지금은 위반 사례가 없지만, "조건부로만 재위임하는" 핸들러를 새로 만들면 그 자리에서 `Dispatch.retractFrom(inst,k,index+1,nil)`을 직접 불러 아래를 정리해야 함. ## 4. 부수 효과 — Slot의 "해제 짝"은 애초에 없어도 됨 (사용자 지적) `base/slot-plan.md`가 "`attachSlot`이 등록한 `Dispatch.setLength`/ `setOffsetSource`에 대응하는 해제 짝이 없다"를 언마운트 전환의 실제 작업량으로 꼽았는데, **새 함수가 필요 없음**: > "옛 오너가 setLength/setOffsetSource 를 그냥 실행해도 된다는 생각. > retract에서 hint 를 보고 Slot이 아니면 그냥 setLength(...0...) > setOffsetSource(...None...) 될 수 있어요." 이건 이미 확정된 관용구 그대로임 — `bind-system-plan.md`의 "실제 마운트를 하지 않는 위치는 `None`을 등록, `setLength`도 짝을 맞춰 `0`". 즉 **해제 = 0/`None`으로 재등록**이고, 별도 unregister API가 아예 필요 없음. **`state>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된 것"으로 수용**(사용자 판단) — `state>`와 같은 범주이고, 평탄화 도구(`research/operator-sugar-plan.md`)로 처리할 요소이며 실사용 케이스가 드묾. Dispatch에 별도 배관을 넣지 않음. ## 5. 남은 열린 항목 — Attribute 이름 소유권 (**하나뿐, 사용자 확인 필요**) 사용자 판단: > "이 방식으로 가면, 자연히 Attribute 의 소유권 충돌은 처리되네요. > 이미 처리된 인덱스에 대해 다시 프로세스 되는것 자체가 UB가 아니고, > 오류 처리가 필요한 곳이면 직접 처리하면 되니까요." **앞부분(재프로세스가 UB 아님)은 동의 — 뒷부분("자연히 처리됨")은 추적해보니 그렇지 않음.** 구체적으로: 그룹 A가 `Dispatch.process(inst, AttributeKey("foo"), sourceA, 1)`로 등록해둔 상태에서 그룹 B가 같은 이름에 `sourceB`로 들어오면: 인덱스 1의 현재 핸들러는 `StoreBind`이고 `sourceB`도 `StoreBind`에 매치되므로 **"같은 핸들러"로 판정되어 조용히 갈아탐.** 그리고 나중에 그룹 A의 클로저가 자기 이름들을 `retractFrom`할 때 **그룹 B의 바인딩을 대신 철거**함(교차 오염). 즉 예전 "조용한 last-write-wins"가 그대로 돌아옴 — 이번 감사에서 고쳤던 바로 그 증상. **즉 "직접 처리하면 되니까"가 맞고, 그 '직접 처리'를 실제로 무엇으로 할지가 유일하게 남은 결정.** 후보: - **(a) 이름별 claimant `Relate`를 Attribute 쪽에 둠** — 2026-08-13 네 번째 세션에 `owners`라는 이름으로 만들었다가 기각됐던 그것. **당시 기각 사유는 지금은 해당 없음**: 그때 버그는 "소유권 반납이 `process`의 `v==nil` 분기에만 있어서 그룹이 이름을 통째로 놓는 경로가 그 분기를 안 타 옛 소유권이 안 지워짐"이었는데, 지금은 **클로저가 항상 불리고 거기서 반납**하면 되므로 그 구멍이 구조적으로 없음. 실질 6줄 정도. - **(b) 감지 포기, 문서로만 금지** — 사용자 코드 실수이므로 UB로 두는 것. 다만 증상이 "조용한 오작동 + 교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 훨씬 나쁨. - **(c) `Dispatch`에 claimant 개념을 일반화** — 이번에 걷어낸 방향이라 다시 넣는 건 반대. **권고: (a).** 기각 사유가 새 모델에서 소멸했고, 소유권 판정을 필요로 하는 유일한 핸들러에만 국소적으로 두는 게 "Dispatch는 diff만 한다"는 이번 방향과도 맞음. ## 6. 반영 범위 (확정 시) - `bind-system-plan.md` — `Dispatch.process` 의사코드, "Dispatch 체인" 절, "핸들러 계약"(힌트 타입 보장 추가, `isX(hintValue)` 가드 규칙 삭제), "Handler 작성 체크리스트" 3번 항목, `StoreBind` 예시(선행 `retractFrom` 삭제), `NoneHandler` 예시, "hintValue는 직속 1단계에만" 항목 삭제. - `tag-plan.md` — 힌트가 항상 `Tag`임이 보장되므로 `isTag(hintValue)` 가드 삭제, 깊은 중첩 캐비엇 삭제. - `slot-plan.md` — 4절대로 "해제 짝 필요" 서술 정정, 언마운트 경로에 `setLength(0)`/`setOffsetSource(None)` 명시. - `attribute-plan.md` — 5절 결정 반영, `process`/클로저 모양 재확정. - **[2026-08-13 7차 감사에서 누락 발견 — 추가]** `architecture.md` — 소스 트리의 `Dispatch/init.luau` 항목(`chains`/`retractFrom` 서술)과 `Attribute.luau` 항목("`retractFrom`에 재귀 위임")이 현행 모델 전제로 쓰여 있음. **이 파일도 최상단에 0-Z 배너를 달고 있는데 이 목록엔 빠져 있었음** — 모든 세션이 "먼저 읽으라"고 지목하는 진입점이라 누락되면 파급이 큼. - **[같이 추가]** `ROADMAP.md` — M2/M4/M6/M10에 "0-Z 먼저 해소할 것" ⚠️ 배너가 달려 있고, 그 배너가 가리키는 체크리스트 항목들(특히 M6의 "`SlotHandler.process`는 claim 실패 시에도 파괴적 클로저를 반환해야 함")이 선행 `retractFrom` 전제로 쓰여 있음 — base 4개를 옮길 때 같이 갱신하고 배너를 걷을 것. - **[2026-08-13 감사에서 추가]** `ref-plan.md` — `bind-system-plan.md` 1단계 분할(2026-08-13 아홉 번째 세션, 순수 이동) 때 "`Ref`의 retract" 절이 옮겨오면서 선행 `retractFrom` 호출 서술도 그대로 딸려왔음. 분할 당시 이 문서(section 6, 당시 아직 안 쓰임)가 없어 배너가 안 붙었던 것 — 반영 시 나머지 6개와 같이 재작성하고 배너 걷을 것. - **[2026-08-13 8차 감사에서 추가] 같은 패스에서 `bind-system-plan.md` 2단계 분할도 할 것** — 이 문서가 지시하는 재작성 범위(핸들러 계약 / 확정된 디스패치 모델 / Dispatch 체인 / Handler 작성 체크리스트)가 곧 분할 예정인 "디스패치 코어" 블록과 정확히 겹침. 어차피 그 텍스트를 전면 재작성하므로, **재작성하면서 새 파일로 옮기면 인바운드 참조 (~37곳)를 한 번만 고치면 됨.** 따로 하면 같은 곳을 두 번 만짐. **요약**: 배너를 달고 있는 파일 = 반영 대상. 위 7개(`bind-system-plan`/ `tag-plan`/`slot-plan`/`attribute-plan`/`architecture`/`ROADMAP`/ `ref-plan`)가 전부이고, 반영이 끝나면 각 파일의 ⚠️ 배너도 같이 제거할 것. --- ## 최종 결말 (2026-08-13 열네 번째 세션) - **5절의 열린 항목은 (a)가 아니라 그 변형으로 확정됨** — 사용자가 제시한 **그룹 전용 키(`GetKey`, 비공개) + `AttributeKeyHandler`의 이름 claim**. (a)(그룹 안에 이름별 claimant `Relate`)를 그대로 쓰면 **그룹↔직접 쓰기 충돌을 못 잡는다**는 게 트레이싱으로 드러났기 때문 — 두 경로가 만나는 유일한 지점인 말단 핸들러에서 `k`가 같은 객체라 소유자를 구분할 수 없음. 근거와 최종 의사코드는 `base/attribute-plan.md` "이름 소유권" 절. - **`Dispatch.retractFrom`은 4-인자에서 3-인자가 됐음** — 값을 넘기는 경로가 `Dispatch.process`의 "같은 핸들러" 분기 하나로 통일되면서, 외부가 힌트를 만들어 넣을 자리 자체가 없어짐(옛 결함이 구조적으로 재발 불가). - 6절의 반영 목록(7개 문서 + `bind-system-plan.md` 2단계 분할)은 같은 세션에 전부 수행됨 — 디스패치 코어는 `base/dispatch-core-plan.md`로 분리되며 재작성됐다.