커밋 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>
232 lines
14 KiB
Markdown
232 lines
14 KiB
Markdown
# 2026-08-21-01 — `Detach` 보존 주체 / `KeyGone` / `Owned` 확정, `attachSlot` 분해
|
|
|
|
**한 줄**: QA 4라운드 followup의 마지막 열린 항목(`F-3`)이 사용자 회신으로
|
|
전량 닫히면서, `:List`의 요소 소유권 모델(`slot._detached` + `KeyGone` +
|
|
`Owned`)과 `attachSlot`의 책임 분해(`materializeSlotTree`/`mountSlotTree`)가
|
|
확정돼 `base/slot-plan.md`에 반영됐다.
|
|
|
|
**소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고,
|
|
처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.md`의
|
|
H절, 분해 근거는 `research/slot-attach-decomposition.md`. 이 파일은 그
|
|
결정들이 **어떤 논의를 거쳐 나왔는지**의 원문 기록이다.
|
|
|
|
---
|
|
|
|
## 1. 발단 — `F-3`을 제안하게 된 관측
|
|
|
|
3차 처리(G절)까지 오면서 `Detach`(옛 `PopOnly`) 경로에 두 개의 구멍이
|
|
남아 있었다.
|
|
|
|
1. **보존 주체가 `userdata`였다.** 기존 문서는 `updateFn`이
|
|
`Detach, { old = ..., source = ... }`를 반환하면 "보존은 반환한 userdata가
|
|
담당"한다고 확정해뒀다.
|
|
2. **키가 데이터에서 사라졌을 때의 처분이 미결**이었다(`question.md` 3번,
|
|
`ROADMAP.md` M6에 ⚠️로 박혀 있었음).
|
|
|
|
2번을 파다가 1번이 함께 무너졌다. 결정적인 근거는 **gcconn 트릭**이다 —
|
|
`base/lifecycle-pattern.md`가 확정해둔 대로 quad가 만든 모든 Instance는
|
|
`GetPropertyChangedSignal("ClassName")` 커넥션의 클로저가 자기 자신과
|
|
`gchold`를 캡처하므로 **참조를 놓는 것만으로는 회수되지 않는다.** 즉
|
|
detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어버리면
|
|
**GC 폴백이 아예 없이 영구 누수**다. `userdata`는 `:List`에게 opaque라
|
|
"이 안에 뭐가 들었으니 죽여라"를 알 수 없다.
|
|
|
|
그래서 세 가지를 묶어 제안했다: (1) 보존은 Slot 필드가 한다, (2) owner가
|
|
죽을 때의 정리는 `Effect`가 한다, (3) 키가 사라지면 `updateFn`에게 한 번
|
|
더 묻는다.
|
|
|
|
## 2. 사용자 회신 — 두 가지를 더 얹었다
|
|
|
|
> "Detach 요소는 slot 안에 보관하는게 내 생각이였어서 (2) 제안에 동의. ud
|
|
> 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach 인데 또 detach 를
|
|
> 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 사용하게
|
|
> 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘."
|
|
|
|
여기서 **"이미 detach인데 또 detach → nop"**과 **"`prev`를 그대로 반환 →
|
|
재마운트"**가 확정됐다. 이 둘은 원래 제안에 없던 것으로, 보존 주체가
|
|
`slot._detached`로 옮겨간 순간 **`ud`에 홀드할 필요 자체가 없어진다**는
|
|
따름정리를 사용자가 먼저 짚은 것이다 — reconcile이 `prev`로 그대로
|
|
돌려주니까 사용자 코드가 `old`를 `ud`에 담아 다니지 않아도 된다.
|
|
|
|
## 3. 반영 — `settle()`이 두 경로를 강제로 합친다
|
|
|
|
구현상 가장 중요한 판단은 **정상 사이클과 소멸 루프가 같은 처분 함수를
|
|
공유하게 만든 것**이다. `settle(key, result, detach, pos)` 하나가
|
|
`wasMounted`/`wasDetached`를 함께 보고 분기한다:
|
|
|
|
- `detach`면 → `rawDetach`(언마운트하되 **소유권 유지**), `_detached`로 이동.
|
|
이미 `_detached`에 있으면 `wasMounted == nil`이라 자연히 nop.
|
|
- `result == prev`인데 `wasDetached`면 → `_detached`에서 빼고 `rawAdd` = 재마운트.
|
|
- `result == nil`이면 → `releaseElement(self, prev, wasDetached ~= nil)`.
|
|
|
|
두 경로가 갈라질 수 없게 만든 이유는 명확하다. 이 코퍼스에서 반복적으로
|
|
났던 버그(`RC-1`/`RC-3`/`RC-4`)가 전부 **"비슷한 두 경로 중 한쪽만 고쳤다"**
|
|
계열이었다.
|
|
|
|
`rawRemove`(소유권 해제 + 파괴) / `rawUnmount`(소유권 해제 + 파괴 안 함) /
|
|
**`rawDetach`(소유권 유지 + 파괴 안 함)** 3형제로 분화한 것도 같은 이유 —
|
|
"파괴하는가"와 "소유권을 놓는가"가 원래 한 축에 뭉쳐 있었는데, `Detach`는
|
|
정확히 **파괴 안 하면서 소유권은 유지**하는 조합이라 기존 둘로는 표현이
|
|
안 됐다.
|
|
|
|
## 4. `Owned` — `Detach`와 직교하는 두 번째 축
|
|
|
|
`C-2`(`:List`의 "밀려난 prev는 dispose"가 `state<Frame>` 의미론과 충돌)를
|
|
닫은 것이 이 축이다. 정리하면:
|
|
|
|
- **`Detach`** = 사이클 단위. "내 건데 잠깐 빼둠."
|
|
- **`Owned`** = 설치 단위. "애초에 내 게 아님."
|
|
|
|
이 둘이 직교한다는 걸 명시하고 나니 충돌이 사라졌다 — 값 교체 시 파괴는
|
|
`Owned = true`일 때 **맞다**(그 요소는 `updateFn`이 만들었고, `:List`가
|
|
안 지우면 아무도 못 지운다). 사용자가 `state<Frame>`에 담아 넘긴 요소는
|
|
`Owned = false`로 설치되고 어떤 경로로도 파괴되지 않는다.
|
|
|
|
## 5. `attachSlot` 분해 — 사용자가 비용 논거로 확정
|
|
|
|
3차 처리에서 `F-4-3`(`setLength` 위치)을 물었을 때, 사용자가 문제를 더 크게
|
|
재정의했다: "attachSlot 의 기능이 너무 다양해진게 문제같음."
|
|
|
|
핵심 진단은 **C6과 C7이 한 함수 안에서 동시에 만족될 수 없다**는 것이었다:
|
|
|
|
- **C6**: 부모에게 미는 길이(`setLength(ownerKey, position, ...)`)는
|
|
flush가 끝나야 정해지는 **최종값**이어야 한다.
|
|
- **C7**: 부기는 물리 트리 조작보다 항상 먼저 끝나야 한다.
|
|
|
|
한 함수엔 `setLength` 슬롯이 하나뿐이라, 앞에 두면 C6 위반(부모가 1회
|
|
헛돎), 뒤에 두면 C7 위반이다. **함수를 둘로 쪼개면 각자 하나씩 만족**한다.
|
|
|
|
사용자가 분해안(B)을 검토하고 낸 판단이 결정적이었다:
|
|
|
|
> "오히려 안 쪼갤 이유가 정당하지 않아보이는게, 안 쪼갰을 때 구현 상 실수가
|
|
> 많아질 위험이 보임."
|
|
|
|
> "이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던
|
|
> 부분이고, 지금 적절한 방향으로 이동하지 않으면 계속 실수에 의한
|
|
> 시간/기술비용이 축적될것 같음. 지금 의사코드를 건들이는 비용이, 추후
|
|
> 실수가 누적되는 비용보다 싸다고 생각함."
|
|
|
|
이건 `conventions.md`의 "드문 오용이나 가상의 미래 요구까지
|
|
방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 **충돌하지 않는다** —
|
|
이 분해가 방어하는 건 가상의 미래 요구가 아니라 **이미 세 번 관측된 버그
|
|
클래스**(`RC-1`/`RC-3`/`RC-4`, 전부 "줄 순서를 잘못 잡아서")이기 때문이다.
|
|
분해하면 그 순서 제약이 줄 순서가 아니라 **함수 경계로 강제**된다.
|
|
|
|
### 사용자가 스스로 답한 우려
|
|
|
|
같은 회신에서 사용자가 유일한 우려를 제기하고 스스로 결론까지 냈다:
|
|
|
|
> "다만 이렇게 된다면 얼마나 nested 든, 하나 마운트되고 하나 마운트되고...
|
|
> 각 객체들이 Parent = inst 되어가며 피지컬에 붙어가는 일자 진행인데, 이건
|
|
> 관측 이후 일괄 등록이라는 차이가 있는듯. 하지만 이미 리스트 액티베이션이
|
|
> 자신의 길이를 정확히 알려면 재귀를 뛰어야하는 부분이고 … 이런 사소한,
|
|
> 문제를 야기하기 어려운 동작을 원래와 동치시키기 위해 디버깅이 어려운
|
|
> 함수를 만드는건 옳지 않아보임."
|
|
|
|
확인해보니 **`Parent` 대입 순서 자체는 안 바뀐다** — 바뀌는 건
|
|
`ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는
|
|
점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는
|
|
동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은
|
|
`research/slot-attach-decomposition.md` 7절.
|
|
|
|
## 6. 이 세션이 안 한 것
|
|
|
|
- **5라운드 문항지를 만들지 않았다** — D절 회신의 "이후 stale 만 잡는것으로
|
|
끝낼 수 있어보임"에 따름.
|
|
- **스파이크 두 건은 미실행**(`01` 재작성 = 단일 generalized `for`,
|
|
`table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`.
|
|
- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는
|
|
**일부러 미뤘다** — 지금 그게 아프다는 증거가 없다.
|
|
|
|
---
|
|
|
|
## 7. 반영 후 감사 — 트레이싱 각도가 문서 대조로는 안 보이던 크래시를 잡았다
|
|
|
|
H절 반영을 커밋한 뒤 사용자 요청으로 `quad-doc-auditor`를 **6라운드** 돌렸다
|
|
(관례대로 한 턴에 하나씩, 라운드마다 각도를 바꿈). 발견 추이:
|
|
|
|
| 라운드 | 각도 | 확실 발견 |
|
|
|---|---|---|
|
|
| 1 | `base/slot-plan.md` 내부 문장 정합성 | 4 |
|
|
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS) | 6 |
|
|
| 3 | `archive/` + 바깥 `base/` + `reference/` | 2 |
|
|
| 4 | **의사코드 시나리오 손 트레이싱** | **3 (실제 크래시)** |
|
|
| 5 | 그 수정분의 회귀 트레이싱 | 3 |
|
|
| 6 | 수렴 확인 | **0** |
|
|
|
|
**이 표에서 배울 것은 "단조 감소하지 않았다"는 점이다.** 1~3라운드는 전부
|
|
"고친 결정이 다른 자리에 안 옮겨졌다"는 **문서 정합성** 유형이었고, 각도를
|
|
트레이싱으로 바꾼 4라운드에서야 **실행이 깨지는 결함**이 나왔다. 문서 대조를
|
|
몇 번 더 돌렸어도 이건 안 나왔을 것이다 — 세 건 다 각각의 문장은 정확했고,
|
|
**서로 다른 두 문서의 정확한 문장이 한 실행 경로에서 만나면 죽는** 종류였기
|
|
때문이다.
|
|
|
|
### 4라운드가 잡은 것의 공통 구조
|
|
|
|
셋 다 **`Detach`가 신설한 새 경로가 기존 불변식과 부딪히는데 그쪽이 안
|
|
고쳐진 것**이다. 특히 `I-1`이 뼈아프다:
|
|
|
|
- `rawDetach`는 **일부러** `releaseOwner`를 안 부른다 — 소유권 유지가 이번
|
|
세션 설계의 핵심이었다.
|
|
- `claimOwner`는 **일부러** 같은 owner의 재클레임도 error다 — 2026-08-13
|
|
감사가 `Slot { a, a }`를 막으려고 엄격하게 만든 것이다.
|
|
- 둘 다 각자 맞는데, **"`prev`를 그대로 돌려주면 재마운트"라는 이 세션이
|
|
확정한 권장 패턴이 정확히 그 교차점을 지난다.** 그대로 구현했으면
|
|
문서가 권장하는 사용법이 첫 실행에서 죽었다.
|
|
|
|
`claimOwner`의 옛 논증 문단이 *"nested에서 '이미 내가 갖고 있는 걸 다시
|
|
클레임'하는 정당한 경로가 하나도 없으므로 무조건 error가 맞음"*이라고
|
|
단정하고 있었는데, 이번 세션이 그 "하나도 없는" 경로를 만들어놓고 그
|
|
문단을 안 읽은 것이다. 5라운드가 그 문단 자체를 따로 잡아냈다.
|
|
|
|
### 내가 감사 결론을 한 번 넘어선 곳
|
|
|
|
`I-2`의 멱등 가드는 사용자가 고른 안이지만, 그것만으로는 반쪽이었다.
|
|
`:List`의 `data:Observer`는 `bindLifetime(inst, observer)`로 **물리 target에
|
|
앵커**돼 있는데 `unmountSlotTree`가 푸는 건 `bk.observers`뿐이라, 가드만
|
|
넣으면 포탈 재마운트 후 **옛 target이 죽는 순간 살아있는 Slot의 `:List`가
|
|
조용히 반응을 멈춘다.** `_listObserver` 핸들을 보관해 앵커만 옮기는 방식으로
|
|
마저 닫았고, 이건 `_detachCleanup`이 이미 받고 있던 처리와 같은 모양이라
|
|
새 패턴을 만든 것도 아니다. 5라운드가 그 수정의 **파괴 경로 누락**(
|
|
`destroySlotTree`에는 안 넣은 것)을 다시 잡았다 — 비대칭 처리를 하나
|
|
추가하면 그 짝을 반드시 같이 훑어야 한다는 걸 다시 확인.
|
|
|
|
### 사용자가 판단한 것 (선택지 3개 제시 → 전부 추천안 채택)
|
|
|
|
1. **재마운트 경로** → `claimOwner`에 `fromDetached` 예외(대안: 전용
|
|
`rawReattach` 신설 / `rawDetach`가 소유권을 놓게 변경).
|
|
2. **`activateList` 재실행** → 멱등 가드(대안: 인자로 전달 / 전용 경로에서만).
|
|
3. **`materializeSlotTree` 중 예외 시 Blocker 잔류** → **문서화만 하고
|
|
`pcall` 안 씀.** 근거는 `conventions.md`의 "드문 오용이나 가상의 미래
|
|
요구까지" 절 — 아직 실제로 밟은 적 없는 경로이고, 에러 경계는
|
|
`base/fallback-plan.md`가 담당한다. 이건 이번 분해가 만든 창이 아니라
|
|
옛 단일 `attachSlot`에도 있었을 구조적 갭이다.
|
|
|
|
### 부수 정리
|
|
|
|
2라운드가 `luau-test/STATUS.md`의 자기 규칙 위반을 잡았다 — "폴더가 곧
|
|
상태"라면서 재작성 대상 스파이크 `01`을 `done/`에 둔 채 ⚠️ 마커만 달아둔
|
|
것. `git mv`로 실제 이동시키고 개수를 맞췄으며, 같이 **`만들어야 할 스파이크`
|
|
절을 신설**했다. "아직 파일조차 없는 실측 항목"은 어느 폴더로도 표현이 안 돼
|
|
구조적으로 잊히는 자리였고, 실제로 followup H-7이 그 소스로 STATUS.md를
|
|
지목했는데 정작 항목이 없었다.
|
|
|
|
### 감사 종료 후 하나 더 — 사용자가 다른 에이전트와 상의해 가져온 두 건
|
|
|
|
`_detachCleanup` Effect 설치를 `mountSlotTree` → `activateList`로 이관,
|
|
그리고 `activateList(self, inst)`의 2번째 인자를 `physicalTarget`으로
|
|
리네이밍. 상세는 followup `I-7`.
|
|
|
|
**여기서 짚어둘 것은 상호작용 하나다.** 이관 제안의 근거가
|
|
*"`materializeSlotTree`가 `_listed`면 재마운트 때도 `activateList`를 다시
|
|
부르니 lifecycle이 보존된다"*였는데, **그건 오늘 오후에 내가 멱등 가드를
|
|
넣기 전의 동작**이고 사실 그게 `I-2`의 버그였다. 가드가 있는 지금은 두 번째
|
|
호출이 early return하므로, 가드 분기가 `_detachCleanup`도 같이 재앵커하지
|
|
않으면 포탈 재마운트 후 detached가 영영 안 치워진다. 결론(이관)은 맞지만
|
|
근거가 한 세대 낡아 있었고, 그 자리를 메워 반영했다.
|
|
|
|
부수적으로 `_listObserver`와 `_detachCleanup`이 **같은 범주로 통일**됐다 —
|
|
"`activateList`가 소유하고 물리 target에 앵커되는 자원"(생성 1회 / 언마운트는
|
|
앵커만 해제하고 핸들 보존 / 재마운트는 가드가 재앵커 / 파괴만 `nil`).
|
|
오늘 5라운드가 잡은 게 "비대칭 처리를 하나 추가하면 그 짝을 반드시 같이
|
|
훑어야 한다"였는데, 아예 비대칭을 없애는 쪽으로 정리된 셈이다.
|