커밋 9b7f847(Detach/KeyGone/Owned/attachSlot 분해) 반영 후 각도를 바꿔가며
quad-doc-auditor를 6라운드 돌린 결과. 라운드별 확실 발견 4/6/2/3/3/0으로
6라운드에서 새 발견 0건 — 수렴 확인 후 종료. 경위 전량은
qa-request/pre-implementation-qa-round4-followup.md의 I절이 소스.
트레이싱 라운드(4~5)가 잡은 실제 크래시 — 셋 다 Detach가 신설한 경로가
기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것:
- I-1 (치명): rawDetach가 소유권을 유지하는데 재마운트는 rawAdd →
claimOwner를 거치고, claimOwner는 같은 owner의 재클레임도 무조건 error다
(2026-08-13 감사가 Slot{a,a}를 막으려고 넣은 것). 문서가 권장하는
"prev를 그대로 반환하면 재마운트" 패턴이 그대로 죽었음. fromDetached
플래그로 그 경로만 좁게 예외 처리.
- I-2: 재마운트된 자식 Slot이 activateList를 두 번 실행해 구독이 이중으로
생기고 mounted/keyIndex 클로저 상태가 통째로 새로 만들어짐 → 멱등 가드.
가드만으로는 :List 구독이 옛 physicalTarget에 앵커된 채 남아 포탈
재마운트 후 조용히 멈추므로, _listObserver 핸들 보관 + 재앵커까지 처리.
- I-3: _detachCleanup이 releaseOwner를 안 불러 Owned=false 요소가 죽은
Slot을 owner로 달고 남음 → 두 분기 공통으로 호출.
- I-6: 위 수정의 회귀 트레이싱 — destroySlotTree에 _listObserver 해제 누락,
claimOwner의 옛 논증 두 문단이 fromDetached와 정면 모순, 소유권 예시
코드가 C-4와 모순.
- I-7: 사용자가 별도 상의해 가져온 두 건 — _detachCleanup 설치를
mountSlotTree → activateList로 이관(:List 없는 Slot마다 no-op Effect를
트리 크기만큼 심고 있었음), activateList의 inst → physicalTarget 리네이밍.
이관 근거가 멱등 가드 이전 동작을 전제하고 있어 가드 분기의 재앵커까지
같이 반영. 이로써 _listObserver/_detachCleanup이 같은 범주로 통일됨.
I-4(materializeSlotTree 중 예외 시 Blocker 잔류)는 사용자 판단으로 pcall
없이 문서화만 — 아직 밟은 적 없는 경로이고 옛 단일 attachSlot에도 있었을
구조적 갭.
문서 정합성 라운드(1~3)에서 나온 것: slot-plan의 "값 교체는 비파괴" 잔존,
분해 완료 후에도 남아 있던 "논의 대기 중" 배너, attachSlot의 flush 루프를
가리키던 문장 5곳, README 색인 행이 2026-08-19에서 멈춰 있던 것,
qa-round4 문항지/followup의 "회신 대기" 상태줄, todos의 용어 목록 이중 소스,
dispatch-core의 raw* 일반 계약에 rawDetach 누락.
luau-test: 스파이크 01이 "재작성 필요" 마커를 단 채 done/에 남아 있어
STATUS.md 자신의 "폴더가 곧 상태" 규칙을 어기고 있었음 → rewrite-required/로
이동하고 개수 정정. "만들어야 할 스파이크" 절 신설(아직 파일조차 없는 실측
항목이 어느 폴더로도 표현되지 않아 구조적으로 잊히던 자리).
doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
1347 lines
88 KiB
Markdown
1347 lines
88 KiB
Markdown
# 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문
|
||
|
||
**상태**: **[2026-08-21] 4차 처리로 종결 + 반영 후 감사까지 완료 — 아래
|
||
I절이 최신.** 감사 4라운드(의사코드 트레이싱)가 **실제 크래시 3건**을 잡아
|
||
같은 날 전부 닫았다(`I-1`~`I-3`). H절이 반영 내용, I절이 그 감사 결과다.
|
||
`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) 분기에서도 반드시 다시 재위임"이 무슨 말인가
|
||
|
||
**질문**: *"무슨말인지 확인 필요"*
|
||
|
||
**답**: **"조건부로만 재위임하는 핸들러를 새로 만들면 고아 체인이 생긴다"**는
|
||
경고다. 지금 존재하는 핸들러엔 위반 사례가 없어서 추상적으로만 적혀 있었다.
|
||
|
||
가상의 위반 예시로 설명하면:
|
||
|
||
```lua
|
||
-- ⚠️ 이런 핸들러를 새로 만들면 위험하다는 뜻
|
||
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` 같은 모양으로
|
||
판별하지 않는 이유가 두 가지다.
|
||
|
||
1. **false positive** — `Modifier` 필드나 `:Peek`가 돌려주는 `T`는 **사용자가
|
||
넣은 임의의 값**이다. 사용자가 우연히 `Compute`라는 필드를 가진 테이블을
|
||
넣으면 quad가 그걸 `State`로 오인한다. 브랜드는 quad가 만든 값에만 찍히므로
|
||
이 오인이 원천적으로 없다.
|
||
2. **인덱싱 자체가 터질 수 있음** — 일부 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`로 세우는가"**이고, 후보가 둘
|
||
있었다.
|
||
|
||
```lua
|
||
-- 후보 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"라는 말이 두 가지로
|
||
읽혀서 생긴 혼동이다.
|
||
|
||
```lua
|
||
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 갱신에 무관한데, 그 이야기가 아닌것임?"*
|
||
|
||
**답**: **섞어 말한 게 맞다. 두 얘기를 분리하면 이렇다.**
|
||
|
||
1. **형제 Slot 사이의 offset** — 사용자 말대로 **자동이고 이 캐비엇과
|
||
무관하다.** nested Slot의 `Length`가 바뀌면 부모가 observe해서 `recompute`가
|
||
돌고 뒤 형제 offset이 갱신된다. 여기엔 문제가 없다.
|
||
2. **같은 `: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` 센티널 신설
|
||
|
||
> **✅ [해소, 2026-08-21] 확정·반영 완료 — 아래 H-2가 결론이다.**
|
||
> 이 절은 그 제안의 원문이다.
|
||
|
||
**사용자 제안**: 키가 데이터에서 사라지면 `updateFn`을 `KeyGone`으로 한 번 더
|
||
불러 처분을 묻고(`T | KeyGone`), `userdata`를 지울지도 사용자가 정하게 위임.
|
||
이걸로 `SL-45`(Detach 홀드 중 키 소멸)가 닫힌다.
|
||
|
||
**방향은 좋아 보인다** — `SL-45`가 "파괴도 반환도 안 되고 참조만 끊긴다"는
|
||
어정쩡한 상태였던 근본 원인이 "키가 사라지는 순간엔 `updateFn`에게 물어볼
|
||
방법이 없다"였고, `KeyGone`이 정확히 그 구멍을 메운다. 다만 **정하지 않으면
|
||
구현이 못 나가는 세부가 넷** 있다:
|
||
|
||
1. **`updateFn`이 `KeyGone`을 받았을 때 반환값의 의미**는?
|
||
제안: `nil` → 파괴(기본), `Detach` → 언마운트만(사용자가 `ud`로 홀드),
|
||
`prev` 그대로 반환 → ? (키가 없는데 계속 마운트해두는 건 모순으로 보임 —
|
||
**error가 맞나, 아니면 파괴로 취급하나?**)
|
||
2. **`userdata` 엔트리는 언제 지워지나?** "유저가 결정"이면 `updateFn`이
|
||
`(result, nil)`을 반환할 때만 지운다는 뜻인데, 그러면 **사용자가 `ud`를
|
||
계속 반환하는 한 `userdata[key]` 엔트리가 영원히 남는다**(키는 이미
|
||
사라졌으므로 다시 물어볼 기회도 없음). 의도된 것인가, 아니면 "한 번
|
||
물어본 뒤엔 무조건 지운다"인가?
|
||
3. **다음 사이클의 소멸 루프가 무엇을 순회하나?** 지금은 직전
|
||
`keyIndex`를 순회하는데, `ud`만 남은 키는 `keyIndex`에 없다. `userdata`
|
||
키까지 합집합으로 순회해야 하나(그러면 매 사이클 그 키를 계속 `KeyGone`으로
|
||
다시 물어보게 됨), 아니면 한 번 물어본 키는 목록에서 빼나?
|
||
4. **`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>` 의미론이 충돌한다
|
||
|
||
> **✅ [해소, 2026-08-21] `Owned` 설치 플래그로 확정 — 아래 H-3이 결론이다.**
|
||
> 이 절은 그 충돌을 처음 짚은 원문이다.
|
||
|
||
**이번 회신에서 나온 것 중 파급이 가장 크다.** 두 답변이 서로 반대 방향을
|
||
가리킨다:
|
||
|
||
- **`SL-43` 답변**: *"'updateFn이 새 값을 반환하면 밀려난 prev는 언마운트만'
|
||
는 이상한듯. 새 값으로 밀려난 prev 는 dispose 되는게 맞음. updateFn 은 직접
|
||
destroy 를 호출 못함 … 그래서 지울 방법이 존재하지 않고, 지워주는게 맞다고 봄."*
|
||
- **`SL-51` 답변**: *"state<Frame> -> 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>`이 교체될 때:
|
||
|
||
1. `attachSlot`이 `setOffsetSource(ownerKey, k, ...)` → `1..k-1` 합이라
|
||
**안 바뀜**(사용자 말대로 여기까진 무해).
|
||
2. `Dispatch.setLength(ownerKey, k, slot.Length)` → `slot.Length`는 아직
|
||
**flush 전이라 0**. Observer "등록 즉시 1회 실행" → 부모 blocker가 꺼져
|
||
있으니 `recompute(부모)`가 **즉시 돈다.**
|
||
3. 그 `recompute`는 `lengthList[k] = 0`으로 계산하므로 **`k+1..N` 위치의
|
||
offset이 전부 줄어든 값으로 `:Set`된다** → 그 아래 `LayoutOrder` 캐스케이드.
|
||
4. 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 시나리오를 따라가면:
|
||
|
||
1. physicalTarget이 Destroy → gcconn 끊김 → 그 `inst`에 `bindLifetime`된
|
||
것들은 `canExecute`가 거짓이 되어 발화가 멈춘다. ✅
|
||
2. 중첩 Slot의 `setLength` Observer는 `bindLifetime(ownerKey=부모Slot,
|
||
observer)`로 묶여 있다 — **`inst`가 아니다.** 그래서 1번만으론 안 끊기고,
|
||
**부모 Slot이 unreachable해질 때** 같이 죽어야 한다.
|
||
3. 부모 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 이 맞는걸로 보이는데 … 실제 구현은 저렇게 알고 있는게 맞음?"*
|
||
|
||
**답: 맞다.** 의사코드 그대로다:
|
||
|
||
```lua
|
||
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 vs `state<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` 자신도 별도로
|
||
들고 있어야** 한다. 이유 셋:
|
||
|
||
1. **`userdata`는 `:List`에게 opaque하다** — 계약상 "안을 전혀 안 들여다본다"
|
||
이므로, 정리 시점에 **뭘 죽여야 하는지 알 수가 없다.**
|
||
2. **소유권이 Slot에 남아야 한다** — `elementOwner`가 여전히 이 Slot을 가리켜야
|
||
detached 요소를 다른 곳에 못 붙인다(안 그러면 "떼어놨는데 남이 가져감").
|
||
3. **파괴 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 뿐임"* 과도 일치).
|
||
|
||
```lua
|
||
-- 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`에 옵션 하나.
|
||
|
||
```lua
|
||
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)의 이유로 안 죽는다.
|
||
|
||
**그래서 필요한 것**(위 제안들의 귀결):
|
||
|
||
1. `slot._detached`를 **필드로** 두고 `destroySlotTree`가 `_elements` 다음에
|
||
그것도 훑어 파괴.
|
||
2. `unmountSlotTree`는 **`_detached`를 안 건드린다**(언마운트는 파괴가 아니고,
|
||
재마운트 시 그대로 이어져야 함).
|
||
3. `Owned = false`면 `destroySlotTree`도 파괴 대신 언마운트.
|
||
4. `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`는 **루프를 두 번 돈다**:
|
||
|
||
```lua
|
||
-- 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`/`PostRef` pre-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` 스케치를 그대로 반영하되 **반복 방향만 역순으로 고쳤다.** 이유:
|
||
|
||
```lua
|
||
if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = 먼저 쓴 쪽이 이김
|
||
```
|
||
|
||
- 정방향(`for i = 1, #input`)이면 배열 **앞쪽** modifier가 먼저 써서 이긴다.
|
||
- 그런데 확정된 규칙은 `modifier-plan.md` 2번의 **"배열 순서상 *나중* 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 액티베이션 → **3 `setLength`** →
|
||
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`+`mount` 2단**(회신의
|
||
"액티베이션 먼저 → length 얻어 밀고 → attach"를 구조화한 것, R6를 부기와
|
||
물리로 쪼개면 **C6·C7을 둘 다 만족**) / (C) 3단(+`Dispatch.drive`까지 같은
|
||
모양으로 수렴) / (D) 문서만.
|
||
- **같이 정해야 하는 것 6가지** — `activateList`의 Observer `bindLifetime`이
|
||
어느 단계인지, 얇은 `attachSlot` 래퍼를 남길지, `Dispatch.drive`도 맞출지,
|
||
prepare만 하고 mount 안 한 중간 상태 처리, `_mounted` 소비처가 새 정의로도
|
||
맞는지, 그리고 **`Detach` 정리용 `Effect`를 어디에 설치할지**.
|
||
- **순서 권고**: 마지막 항목 때문에 **`F-3`이 먼저 닫히는 게 낫다** — 그
|
||
결정이 이 분해의 요구사항을 하나 더 얹는다.
|
||
|
||
## G-3. 아직 확인 안 된 것 — `F-3`의 나머지
|
||
|
||
**`F-3`은 (4)번만 확인을 받았다.** (1)~(3)과 "+"(dispose 재귀)는 아직
|
||
답이 없어서 **base에 아무것도 안 넣었다.** 요지만 다시 줄이면:
|
||
|
||
1. **detached 요소가 영구 누수인 이유** — gcconn 트릭 때문에 quad-제작
|
||
Instance는 자기 시그널 커넥션이 자기를 살린다("참조를 놓는 것만으로는
|
||
회수되지 않고 반드시 `Destroy`로 회수된다", `lifecycle-pattern.md`) →
|
||
**GC 폴백이 아예 없다.** 명시적 정리 경로가 필수.
|
||
2. **그래서 detached는 `userdata`가 아니라 `slot._detached` 필드가 들고
|
||
있어야 한다** — `userdata`는 `:List`에게 opaque라 뭘 죽여야 할지 모르고,
|
||
소유권이 Slot에 남아야 남이 못 가져가며, `destroySlotTree`의 walk가
|
||
닿아야 한다. 부수 이득으로 **다음 사이클에 `prev`로 그대로 돌려줄 수
|
||
있어 `ud` 홀드가 불필요**해진다.
|
||
3. **owner 죽음 처리는 `Effect`**(제안하신 그대로), 단 **소유 층위는
|
||
`activateList`가 아니라 `attachSlot`/`unmountSlotTree` 쌍** — Slot이 다른
|
||
`physicalTarget`에 재마운트되면 Effect도 옮겨야 하고, 안 그러면 옛 target이
|
||
죽을 때 **살아있는 Slot의 detached를 파괴**한다.
|
||
4. **`KeyGone` 후 "다시 안 묻기"는 자동 성립** — 소멸 루프가 `keyIndex`만
|
||
도니까 사라진 키는 다음 사이클 대상이 아니다. 남은 미결은 **`KeyGone`에
|
||
`prev`를 그대로 반환하면?**(→ `error` 추천)과 **`index` 인자**(→ `0` 추천).
|
||
5. **"+" `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`) → Blocker `On`
|
||
→ 자식 재귀/`setLength` → `OffWithoutEmit` → `recompute` →
|
||
**마지막에 `setLength(ownerKey, position, slot.Length)`**.
|
||
- **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과
|
||
`_mounted = true`만. Blocker 불필요. (**[정정, 2026-08-21 `I-7`]** 여기
|
||
`_detachCleanup` Effect 설치도 있었으나 `activateList`로 이관됐다 —
|
||
이제 이 함수는 정말로 물리 대입만 한다.)
|
||
- **공개 `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.md` 3번의 관련 항목**은 이 처리로 전부 닫혔다.
|
||
- 사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는
|
||
것으로 끝낼 수 있어보임"(D절 회신).
|
||
- 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`),
|
||
`table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는
|
||
`luau-test/STATUS.md`.
|
||
|
||
---
|
||
|
||
# I절 — 반영 후 감사 (2026-08-21): 트레이싱이 실제 크래시 3건을 잡음
|
||
|
||
H절의 반영을 커밋한 뒤 `quad-doc-auditor`를 각도를 바꿔 4라운드 돌렸다.
|
||
1~3라운드(문서 대조)에서 나온 것은 전부 "고친 결정이 다른 자리에 안
|
||
옮겨졌다" 유형이었고, **4라운드(의사코드 시나리오 손 트레이싱)에서 실제로
|
||
실행이 깨지는 결함 3건**이 나왔다. 셋 다 **`Detach`가 신설한 새 경로가
|
||
기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것**이다.
|
||
|
||
## I-1. detach 재마운트가 `claimOwner`에서 무조건 크래시 (⭐⭐ 치명)
|
||
|
||
`rawDetach`는 일부러 `releaseOwner`를 안 부른다(소유권 유지가 설계의
|
||
핵심). 그런데 재마운트는 `rawAdd`를 거치고, `rawAdd`가 부르는
|
||
`claimOwner`는 **같은 owner의 재클레임도 무조건 error**다 — 2026-08-13
|
||
감사가 `Slot{a, a}`를 막으려고 일부러 엄격하게 만든 것이다. 결과:
|
||
**문서가 권장하는 "다음 사이클에 `prev`를 그대로 돌려주면 재마운트" 패턴이
|
||
그대로 `error("이 요소는 이미 마운트돼 있음")`로 죽는다.**
|
||
|
||
**확정(사용자 판단)**: `claimOwner`에 **detach 재마운트 전용 예외**를 넣는다
|
||
— `claimOwner(element, ownerKey, fromDetached)`에서 `fromDetached and
|
||
cur == ownerKey`일 때만 통과. top-level `claimOwnerAt`이 이미 "같은
|
||
`(inst, k)` 자리의 재발행은 통과"라는 같은 모양의 예외를 갖고 있어 대칭이
|
||
맞는다. **`fromDetached` 없이 "같은 owner면 통과"로 완화하면 안 된다** —
|
||
`Slot{a, a}`가 다시 새어나간다.
|
||
|
||
## I-2. 재마운트된 자식 Slot이 `activateList`를 두 번 실행 (⭐⭐)
|
||
|
||
`I-1`을 고치면 바로 드러나는 다음 문제. `rawAdd`는 Slot 요소를 이미
|
||
마운트된 부모에 넣을 때 `attachSlot`을 부르고, `materializeSlotTree`는
|
||
`slot._listed`면 **무조건** `activateList`를 다시 실행한다. `:List`를 가진
|
||
자식 Slot이 detach에서 돌아오면 `data:Observer` 구독이 하나 더 생기고
|
||
`mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져**
|
||
기존 요소를 전부 새 것으로 오인해 다시 그린다.
|
||
|
||
**확정(사용자 판단)**: `activateList`에 **멱등 가드**(`slot._listActivated`).
|
||
`_crudUsed`/`_listed`와 같은 결의 플래그다.
|
||
|
||
**가드만으로는 반쪽이라 하나 더 닫았다** — `:List`의 `data:Observer`는
|
||
`bindLifetime(inst, observer)`로 **물리 target에 앵커**돼 있는데,
|
||
`unmountSlotTree`가 푸는 건 `bk.observers`뿐이라 이 구독은 **옛
|
||
physicalTarget에 매달린 채** 남는다. 포탈로 다른 target에 재마운트하면
|
||
옛 target이 죽는 순간 살아있는 Slot의 `:List`가 조용히 반응을 멈춘다.
|
||
그래서 `slot._listObserver`로 핸들을 보관하고, `unmountSlotTree`가
|
||
`unbindLifetime`만 하고(핸들과 `_listActivated`는 보존), 멱등 가드가
|
||
`bindLifetime(inst, self._listObserver)`로 앵커를 새 target에 다시 건다.
|
||
`_detachCleanup`이 이미 받고 있던 처리와 같은 모양이다.
|
||
|
||
## I-3. `_detachCleanup`이 `releaseOwner`를 안 부름 (⭐)
|
||
|
||
owner가 죽을 때 `_detached`를 비우는 `Effect`가 `_owned == false` 분기에서
|
||
요소를 파괴하지 않는 건 맞는데, **소유권 기록도 안 푼다.** 그러면 그
|
||
`state<Frame>`이 **죽은 Slot을 owner로 달고** 남아, 사용자가 같은 값을
|
||
다른 Slot에 넣을 때 그 죽은 Slot이 GC되기 전까지 비결정적으로 "이미
|
||
마운트돼 있음" error가 난다. 이 문서 스스로 "소유권 반납은 GC에 맡기면
|
||
안 됨" 절에서 경계했던 실패 모드다.
|
||
|
||
**반영**: 두 분기 공통으로 `releaseOwner(element, slot)`를 먼저 부른다
|
||
(`rawRemove`도 파괴 전에 부르므로 대칭).
|
||
|
||
## I-4. `materializeSlotTree` 중 예외 시 Blocker가 켜진 채 남음 — 문서화만
|
||
|
||
`blocker:On()`과 `OffWithoutEmit()` 사이에서 자식 재귀가 예외를 던지면
|
||
Blocker가 영구히 켜져 그 Slot의 `Length`가 영원히 stale해진다.
|
||
|
||
**확정(사용자 판단)**: `pcall`로 감싸지 않고 **문서화만 한다.** 마운트
|
||
도중 예외는 quad가 복구를 보장하지 않는 상태이고(에러 경계는
|
||
`base/fallback-plan.md`), 아직 실제로 밟은 적 없는 경로다 —
|
||
`conventions.md`의 "드문 오용이나 가상의 미래 요구까지" 절이 세운 원칙
|
||
그대로. 이건 이번 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도
|
||
있었을 구조적 갭이다.
|
||
|
||
## I-5. 문서 대조 라운드(1~3)에서 나온 것
|
||
|
||
전부 "고친 결정이 다른 자리에 안 옮겨졌다" 유형이라 여기 나열하지 않는다
|
||
— 커밋 diff가 소스. 대표적인 것만: `slot-plan.md`가 한쪽에선 `Owned`
|
||
기준 표를, 다른 쪽에선 옛 "값 교체는 비파괴"를 동시에 서술하고 있었고,
|
||
`attachSlot`의 flush 루프를 가리키던 문장 5곳이 `materializeSlotTree`로
|
||
안 옮겨져 한 문서 안에 신/구 표현이 공존했으며, `luau-test/STATUS.md`가
|
||
자기 규칙("폴더가 곧 상태")을 어기고 재작성 대상 스파이크를 `done/`에
|
||
두고 있었다.
|
||
|
||
## I-6. 5라운드(수정분 회귀 트레이싱) — 3건 더, 전부 반영
|
||
|
||
`I-1`~`I-3`의 수정 자체를 다시 트레이싱한 라운드. 새 결함은 안 나왔지만
|
||
**그 수정이 닿았어야 할 자리 3곳**이 나왔다.
|
||
|
||
1. **`destroySlotTree`가 `_listObserver`를 안 푼다.** `unmountSlotTree`엔
|
||
넣었는데 파괴 경로엔 빠졌다. `physicalTarget`은 중첩 깊이와 무관하게
|
||
**트리 최상위 inst 하나**라(`materializeSlotTree`가 같은 값을 재귀에
|
||
그대로 넘김), 자식 Slot만 죽고 그 inst는 살아있는 게 흔한 경우 —
|
||
그러면 `gchold[inst]`가 observer와 그 클로저 상태(그리고 죽은 slot
|
||
자신)를 계속 붙잡고, `data`가 emit될 때마다 이미 죽은 자식들에 대해
|
||
reconcile이 계속 돈다. **반영**: 파괴이므로 `unmountSlotTree`와 달리
|
||
핸들과 `_listActivated`까지 `nil`로 지운다.
|
||
2. **`claimOwner`의 옛 논증 두 문단이 `fromDetached`와 정면 모순.**
|
||
2026-08-13 세션의 *"nested엔 재클레임이라는 개념이 애초에 없다 …
|
||
무조건 error가 맞음"*이 그대로 남아 있었다 — 그 논증의 근거(reconcile은
|
||
항상 release → claim 순서)가 `Detach`로 깨졌는데 갱신이 안 따라갔다.
|
||
함수 정의 옆 주석만 정정돼 있었다. **반영**: 두 문단에 ⚠️ 정정을 달고,
|
||
"플래그 없이 같은 owner면 통과로 완화하면 `Slot { a, a }`가 다시
|
||
새어나간다"는 경계도 같이 명시.
|
||
3. **소유권 예시 코드가 `C-4`와 모순.** `-- destroySlotTree(slot) 안,
|
||
자식들을 파괴하기 직전` + `releaseOwner(element, slot)` 예시가
|
||
2026-08-12 서술로 남아 있었는데, 2026-08-20 `C-4`가 그 호출을 도로
|
||
뺐고 실제 의사코드도 "부르지 **않는다**"라고 명시한다. 이번 감사
|
||
각도(`releaseOwner` 이중 호출 추적)에서 걸렸다. **반영**: 예시를
|
||
삭제 표시로 교체.
|
||
|
||
**회귀 없음으로 확인된 것**: `claimOwner`의 early return은 `OWNER_POS`를
|
||
stale하게 남기지 않고(그 필드는 top-level `claimOwnerAt` 전용),
|
||
`fromDetached`를 안 넘기는 기존 호출부는 동작이 이전과 완전히 동일하며,
|
||
`_listActivated` 가드는 정상 마운트/`Slot:List()` 경로를 막지 않고,
|
||
언마운트~재마운트 구간에도 `_listObserver`는 Slot 필드 강참조라 GC되지
|
||
않으며, `releaseOwner` 이중 호출 경로는 없다(`destroySlotTree`가 `C-4`
|
||
이후 자식에 대해 안 부르므로).
|
||
|
||
|
||
## I-7. `_detachCleanup`을 `activateList`로 이관 + `activateList`의 `inst` 리네이밍
|
||
|
||
사용자가 별도 에이전트와 상의한 내용을 가져와 확정한 두 건. 둘 다 **의미
|
||
변화 없는 배치/이름 정리**지만, 첫 건은 실제 낭비를 없앤다.
|
||
|
||
1. **`_detachCleanup` Effect 설치가 `mountSlotTree` → `activateList`로 이관.**
|
||
`_detached`를 채우는 유일한 지점이 `settle`의 `rawDetach`이고 그건
|
||
`activateList` 클로저 안에만 있으므로, **`:List`가 없는 Slot의
|
||
`_detached`는 정의상 영원히 빈 테이블**이다. 그런데 `mountSlotTree`는
|
||
재귀 전체(중첩 포함)에 Effect + `bindLifetime`을 심고 있었다 — 트리
|
||
크기만큼의 no-op. 확인: `rawDetach` 호출부는 `settle` 한 곳뿐이고
|
||
`_detached`에 쓰는 코드도 전부 그 클로저 안이다.
|
||
|
||
**이관하면서 `_listObserver`와 완전히 같은 취급으로 통일했다** —
|
||
생성은 `activateList` 최초 1회, `unmountSlotTree`는 **앵커만 해제하고
|
||
핸들 보존**, 재마운트는 멱등 가드가 재앵커, `destroySlotTree`만 해제 +
|
||
`nil`. 이전엔 이 둘이 서로 다른 함수에서 만들어지고 언마운트 처분도
|
||
갈렸다(하나는 보존, 하나는 `nil`). 이제 **"`activateList`가 소유하고
|
||
물리 target에 앵커되는 자원"**이라는 한 범주가 되어, 이런 자원이 또
|
||
생겨도 훑을 자리가 하나다.
|
||
|
||
**⚠️ 이 이관에는 `I-2`의 멱등 가드와의 상호작용이 걸려 있다.** 원
|
||
제안의 근거는 *"`materializeSlotTree`가 `_listed`면 재마운트 때도
|
||
`activateList`를 다시 부르니 lifecycle이 보존된다"*였는데, 그건
|
||
**가드를 넣기 전 동작**이다(그리고 그게 `I-2`의 버그였다). 가드가 있는
|
||
지금은 두 번째 호출이 early return하므로, **가드 분기가
|
||
`_detachCleanup`도 같이 재앵커하지 않으면** 포탈 재마운트 후 그 Slot의
|
||
detached는 owner 사망 시 아무도 치우지 않는다. 가드 분기에서 두 자원을
|
||
같이 `bindLifetime`하도록 반영했다.
|
||
|
||
`_detached` **테이블 자체는 Slot 필드로 그대로 둔다** —
|
||
`destroySlotTree`가 `activateList` 클로저 **밖**에서 walk해야 하는 게
|
||
애초에 `ud`를 버리고 필드로 간 이유다(`I-1`의 (2)번 근거).
|
||
|
||
2. **`activateList(self, inst)` → `activateList(self, physicalTarget)`.**
|
||
그 인자는 `bindLifetime`의 첫 인자로 들어가니 타입상 물리 Instance일
|
||
수밖에 없고, 호출부 둘(`materializeSlotTree`의 `physicalTarget`,
|
||
`Slot:List()`의 `self._mountedInst`)이 넘기는 값도 같은 개념이다.
|
||
Slot일 수는 없다 — 그 Slot은 이미 1번째 인자 `self`다. 옆 함수들
|
||
(`materializeSlotTree`/`mountSlotTree`/`attachSlot`)과 이름을 맞춘
|
||
**순수 리네이밍**.
|