quad/.claude/research/dispatch-redispatch-diff-plan.md
qwreey 8c784288b0
audit(dispatch,slot): c33ae04 전체 감사 — 실제 버그 4건 수정, Slot 언마운트 전환, 재디스패치 모델 재설계안
c33ae04(인덱스 기반 Dispatch 재설계) 전체를 직접 정독. 방향은 옳으나 새로 쓴
의사코드가 손 트레이싱을 안 거친 채 커밋됐음이 드러나 실제 버그 4건 발견·수정:

1. Dispatch.process가 chains:SetStrong을 h.process 뒤에 둬서 최초 마운트에서
   하위 위임 retractor가 통째로 유실(재귀가 자기 테이블을 만들었다 바깥이
   덮어씀 — Slot이면 서브트리 중복 마운트로 직결). SetStrong hoist + 재귀 중
   hole 방지용 no-op 점유 마커 추가.
2. Attribute 그룹이 process에서 retractFrom을 선행 호출해 인덱스 1이 무조건
   비워지는 바람에 점유 체크(=소유권 충돌 감지)가 전혀 작동 안 함. 철거를
   반환 클로저로 이동.
3. SlotHandler가 claim 실패에도 파괴적 클로저를 반환 — nested는 엄격
   claimOwner, top-level은 claimOwnerAt(inst,k)로 분리. rawRemove의
   releaseOwner 누락, destroySlotTree의 GC 타이밍 의존 오류도 수정.
4. Ref retractor가 spurious 재발행에서도 relate를 지워 dedup 무력화.

사용자 설계 결정:
- State<Slot> 교체 = 파괴가 아니라 언마운트(state<Frame>와 동일). 포탈이
  별도 기능이 아니라 이 결정의 귀결이 됨. 해제는 setOffsetSource(None) →
  setLength(0) 순서 고정(반대면 죽는 중인 서브트리 Source에 헛된 Set이
  날아감). dispose(value) 신설 — 트리가 아직 요구하면 파괴 거부하고 error.
- 재디스패치를 "하강 diff"로 재설계 — 래핑 핸들러의 retractFrom 선행 호출을
  폐기하고 Dispatch.process가 핸들러를 먼저 비교. 힌트가 None/래퍼로 오염돼
  깜빡임 방지가 조용히 꺼지는 결함이 계기. 아직 research/에만 있고 base
  미반영(Attribute 이름 소유권 하나가 남음) — base 4개 문서에 경고 배너.

전파 누락 정리: ROADMAP.md가 2026-08-08 이전 모델로 남아있던 것, base 내
"3종 vs 4종 계약" 모순 6개 문서, luau-test/04가 없어진 가드를 검증하던 것
(04는 인덱스 모델 + 버그 1 재현 음성 대조군으로 전면 재작성).

재발 방지: bind-system-plan.md "Handler 작성 체크리스트"(7항목),
relate-plan.md "언제 Relate를 쓰고 언제 쓰면 안 되는가" 신설.

다음 세션 최우선: question.md 0-Z(Attribute 이름 소유권) — 사용자가 직접
스케치하며 심층 분석 예정.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:01:49 +09:00

240 lines
13 KiB
Markdown

# 재디스패치 = 하강 diff — `retractFrom` 선행 폐기, 핸들러 비교를 클로저 호출 앞에 (설계안)
**상태**: research — 2026-08-13 여섯 번째 세션에 사용자가 제기하고 방향을
제시, 같은 세션 후속 라운드에서 모델이 거의 확정됨. **`base/` 반영 전
남은 열린 항목은 아래 5절의 하나뿐**(Attribute 이름 소유권). 그 하나만
정해지면 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/
`attribute-plan.md`를 한 번에 옮기면 됨.
> **문서 이력**: 최초엔 `dispatch-hint-to-oldvalue-plan.md`라는 이름으로
> "힌트 대신 `oldValue`를 넘기자"는 보완안을 담고 있었으나, 사용자가
> **"이전 값인 oldValue는 처음부터 클로저라 이미 본인이 알지 않아요?"**
> 라고 지적해 그 보완안이 통째로 불필요함이 드러남(아래 3-2) — 파일명도
> 바꿈.
## 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`은 "가드가 없으면 마운트된 서브트리
전체가 파괴됐다 재생성"이라 파급이 큼.
## 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<State<Tag>>`에서 바깥이 새 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<state<Frame>>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된
것"으로 수용**(사용자 판단) — `state<state<Tag>>`와 같은 범주이고, 평탄화
도구(`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`/클로저 모양 재확정.