4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항). 범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup / quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것 (Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐 받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다. 주요 확정/역전: - slot._detached lazy화, KeyGone엔 새 값 반환도 error, Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount) - Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음) - raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결. 래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸 - 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) — base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음 - Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커 분리, 4라운드 D-56 역전(archive로) - Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom), setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정 - recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던 결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈), bk.N or 0(빈 Slot 크래시) - Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦" 역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k - 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로 바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목) 새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료), state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성 결정이라 M3 전 결론 필요). 감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순, 그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는 M3에서"로 남아 있던 사각지대. doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md (A~K절, 마지막이 최신). Co-authored-by: qwreey <me@qwreey.moe>
602 lines
40 KiB
Markdown
602 lines
40 KiB
Markdown
# 라이프사이클 패턴 — rbvm의 `Connected` + GC 관용구 채택
|
|
|
|
**상태**: 결정됨(base) — quad-v2가 채택할 라이프사이클/정리(`retract`) 전략의 원본.
|
|
완료 개념 없음, 구현하면서 세부 조정 있을 수 있음.
|
|
|
|
**참고(2026-08-06)**: rbvm은 사용자가 직접 실행해 실제로 잘 동작하는 것을 확인한
|
|
코드베이스(프로덕션에서 검증됨) — 사람이 짠 코드라 무결성이 100% 보장되는 건
|
|
아니지만(실제로 아래 5번 항목에서 `Connected` 체크 방향 반전 버그, `__mode` 오타
|
|
버그가 발견됨), 다른 리서치 참고 레포보다 "실물로 돌아간다"는 근거가 확실한
|
|
비교 대상. 단 이건 참고용 비교 대상일 뿐 quad-v2가 반드시 따라야 할 규범은
|
|
아님 — 실제 채택 여부는 이 문서 각 절의 확정 내용(특히 아래 "확정" 두 절)을
|
|
따를 것, rbvm 코드를 그대로 베끼라는 뜻이 아님.
|
|
|
|
## 배경
|
|
|
|
`raw-userinput.md`(이하 사용자 원 메모): "레지스터의 실행은 내 생각엔 그냥 Destroy
|
|
되었는지를 라이프타임 홀더의 Connected 상태로 보는게 맞는듯", "여전히 connection
|
|
으로 해키한 방법 쓸듯. destroying으로 자료구조 계속 바꿔가는거 비용 큼. gc 네이티브
|
|
에 맡겨야함. 대신 이젠 Connected 필드로 이 연결이 살아 있는지 보고 설정 가능함.
|
|
이게 rbvm 쪽에서 구현되어있음."
|
|
|
|
rbvm(`.claude/initreq/rbvm/`)을 조사한 결과, 정확히 이 패턴이 존재함. 아래는
|
|
그 조사 결과 요약(전체는 이 문서 작성에 쓰인 리서치 세션 참고, 소스는
|
|
`.claude/initreq/rbvm/src/signal.luau`, `src/proxy/base.luau`, `src/namespace.luau`).
|
|
|
|
## 채택할 패턴
|
|
|
|
### 1. `Connected`는 저장되는 bool이 아니라 계산된 속성
|
|
|
|
rbvm의 `Connection` 타입(`signal.luau:21-24`)은 `Connected`를 실제 필드로 두지 않고
|
|
`__index` 메타메소드에서 계산함:
|
|
|
|
```luau
|
|
function ConnectionMeta.__index(self: Connection, key: string): any
|
|
if key == "Connected" then
|
|
local data = Connection.GetPrivate(self)
|
|
return data.Signal ~= nil
|
|
end
|
|
end
|
|
```
|
|
|
|
연결 해제 시 `data.Signal = nil`만 하면 됨(`Connection.Dispose`) — 자료구조를
|
|
바로 지우거나 재구성하지 않음.
|
|
|
|
> **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `LP-1`] quad는 이 rbvm 코드를
|
|
> 채택하지 않는다 — quad에서 `Connected`는 "계산된 속성"이 아니라 그냥 Roblox
|
|
> `RBXScriptConnection`의 네이티브 필드다.** 옛 서술("quad-v2도 이 모양을 그대로
|
|
> 채택: 라이프타임 홀더는 '내가 아직 살아있게 하는 뒷받침 참조'가 nil인지만
|
|
> 확인하면 됨")은 rbvm의 프록시 계층 사정을 quad에 잘못 옮긴 것이었다. 사용자
|
|
> 판정: *"Connected 는 단순히 RBXScriptConnect 안의 속성이고, Destroy 수행 시
|
|
> 모든 커넥션이 죽으니 자연스럽게 Connected 가 false 이 되는것 뿐임. nil로
|
|
> 참조를 만들 이유도 없음."*
|
|
>
|
|
> **quad의 실제 판정은 두 상태뿐**(`isBoundAlive`, 아래 "(1)" 코드 블록):
|
|
> 1. **gcconn 자체가 없음** — 아직 바인드 안 됐거나, 이미 GC돼서 weak 릴레이션
|
|
> 항목이 비워진 상태. `BindData:GetWeak(value, "gcconn")`이 `nil`.
|
|
> 2. **gcconn은 있는데 `.Connected == false`** — `inst`가 방금 Destroy됐고
|
|
> 아직 GC는 안 된 구간. 엔진이 Destroy 시점에 모든 커넥션을 끊어주므로
|
|
> quad가 아무것도 안 해도 이 값이 저절로 뒤집힌다.
|
|
>
|
|
> **quad가 `Signal = nil`처럼 직접 참조를 끊는 자리는 없다** — 유일하게 "직접
|
|
> 끊는" 동작인 `unbindLifetime`도 `gchold[value] = nil`과 `BindData` 항목 제거일
|
|
> 뿐 커넥션 자체를 만지지 않는다. rbvm에서 실제로 가져오는 건 **"gcconn 트릭으로
|
|
> Instance 수명에 값을 매단다"는 관용구 하나**이고, `Connected`를 계산 속성으로
|
|
> 만드는 구현은 가져오지 않는다.
|
|
|
|
rbvm에서 실제로 재사용하는 부분은 아래 "(0)"/"(1)" 절의 gcconn/gchold
|
|
관용구이고, 위 `__index` 계산 속성 코드는 **참고용 원본 인용**으로만 남긴다.
|
|
|
|
### 2. Instance 파괴는 `Instance.Destroying` 훅 하나로만 관측
|
|
|
|
rbvm은 실제 Roblox Instance의 파괴를 감지하는 지점을 단 하나로 좁혀둠 —
|
|
`inst.Destroying:Connect(...)` (`proxy/base.luau:150-156`), `Destroyed` 같은
|
|
플래그를 그 콜백에서만 true로 뒤집음. `AncestryChanged`나 폴링 방식은 안 씀.
|
|
quad-v2도 동일: 인스턴스 라이프사이클 훅 지점은 `Destroying` 하나로 통일.
|
|
|
|
**[구체화, 2026-08-20 구현 전 QA 4라운드 `LP-2`] "예상보다 적을 수 있다"가 아니라
|
|
지금은 정확히 한 곳뿐이다 — `Effect`.** 아래 "2026-08-04 검증 라운드에서 보강된
|
|
내용" 절이 "이 훅을 쓰는 지점이 예상보다 적을 수 있다"고만 열어뒀던 걸 사용자가
|
|
확정해줌(*"당장은 Effect 뿐임"*). `Effect`의 leaf-death cleanup(`base/effect-plan.md`)이
|
|
이 훅을 쓰는 유일한 소비자이고, 그 위의 슈가 `OnDestroyed`(`base/lifecycle-hooks-plan.md`)도
|
|
결국 같은 경로다. 나머지(Observer 게이팅, Tag/Attribute 정리, Tween 취소)는 전부
|
|
gcconn `Connected` 판정이나 엔진 자체 정리로 커버되어 이 훅을 안 씀.
|
|
|
|
### 3. 정리(`retract`)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager)
|
|
|
|
rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를
|
|
저장 — 홀더 객체를 아무도 안 들고 있으면 그 private 레코드도 자동으로 사라짐.
|
|
즉시 처리하는 예외는 딱 두 가지뿐이었음: (a) 가상 트리의 부모/자식 포인터처럼
|
|
방치하면 죽은 참조를 계속 순회하게 되는 "작고 유계(bounded)"인 것, (b) 네임스페이스
|
|
전체가 통째로 죽을 때의 순서 있는 dispose 훅. **quad-v2 원칙: 기본은 GC 위임,
|
|
즉시 정리는 "안 끊으면 죽은 참조를 순회하게 되는 작은 포인터"류에만 국한.**
|
|
|
|
### 4. (참고 기록) rbvm의 Signal 자체는 재사용 가능한 범용 emitter였음 — 실제로는 채택 안 함
|
|
|
|
`signal.luau`의 `Signal`/`Connection` 클래스는 rbvm 프록시 시스템에 의존하지
|
|
않는 범용 이벤트 emitter임 (`Connect`/`Once`/`Wait`/`Fire`/`Destroy`,
|
|
`IsInited`/`OnInit`/`OnUninit` 지연 활성화 훅 포함). 사용자 원 메모에는
|
|
"시그널 자체 구현은 아닌듯... 콜백 정도로도 충분"이라는 언급이 있어 한때
|
|
이 문서 초안 단계에서 상충하는 것처럼 보였으나, **이 질문은 2026-08-04
|
|
검증 라운드에서 최종 확정으로 재확인됨 — 더 이상 열린 질문 아님**
|
|
(`base/architecture.md` 11번 항목도 동일하게 명시). 결론은 아래 "확정: Signal
|
|
클래스는 안 만든다" 절 참고 — 커스텀 `Signal`/`Connection` 클래스는 만들지
|
|
않고, 콜백 + `Connected` 계산 속성만 채택한다.
|
|
|
|
### 5. rbvm에서 그대로 가져오면 안 되는 것 (버그 발견됨)
|
|
|
|
- `proxy/base.luau:72-78`의 `Proxy.DisposeNamespace`와 `signal.luau:401-408`의
|
|
`SignalProxy.DisposeNamespace`가 `Connected` 체크 방향이 서로 뒤집혀 있음
|
|
(하나는 "아직 연결되어 있으면 continue", 다른 하나는 정반대) — 후자가 맞는
|
|
방향(`not Connected`일 때만 skip, 살아있으면 Disconnect). quad-v2 구현 시
|
|
이 반전 버그를 복사하지 않도록 주의.
|
|
- `namespace.luau:5-6`의 `ItemNamespaceMap`은 `__mod = "k"`로 오타가 나 있어서
|
|
실제로는 weak table이 아님(`__mode`가 맞음) — 그대로 베끼면 메모리 누수.
|
|
- `InitNamespace`/`Registered`-가드/`NewLib` 3종 세트로 "라이브러리마다 하나하나
|
|
수동 init" 하는 방식은 정확히 사용자가 피하고 싶다고 한 패턴 — 순서 있는
|
|
dispose-hook 리스트 자체는 재사용해도, 수동 init 관례는 그대로 베끼지 말 것
|
|
(팩토리 함수로 대체 — `base/module-lifecycle-plan.md` 참고).
|
|
|
|
## 확정: Signal 클래스는 안 만든다
|
|
|
|
**사용자 확인 완료** — 콜백 + `Connected` 계산 속성만으로 간다. rbvm의 범용
|
|
`Signal`/`Connection` 클래스 전체는 가져오지 않음. rbvm에서 채용하는 건 오직
|
|
"`Connected`가 계산된 속성" 이라는 패턴 자체뿐.
|
|
|
|
## 확정: quad는 자신이 만든 Instance의 라이프사이클 "중간"에 있지 않다
|
|
|
|
이게 이 문서 전체의 핵심을 재정의하는 결정. rbvm의 proxy는 Instance와 소비자
|
|
사이에 자신을 끼워넣는(중간 계층) 설계라 "내가 사라질 때 무언가를 정리해야
|
|
하는가"라는 문제가 생기지만, **quad는 자신이 만든 Instance를 그 Instance의
|
|
생명주기 끝까지 그대로 들고 있는 소유자다 — 중간에 끼는 계층이 없음.**
|
|
|
|
결론: **Instance/바인드 전체가 Destroy될 때 실행해야 하는 정리(teardown) 로직은
|
|
없다.** 오히려 있으면 안 됨 — Destroy 이후에 그 Instance에 프로퍼티를 셋하거나
|
|
메서드를 호출하면(예: 이미 죽은 Tween에 `:Cancel()`) 그냥 에러남. 대상이
|
|
Destroy되면 그 대상에 묶인 것들(Tween 등)도 자연히 죽은 상태가 됨 — GTK 등
|
|
다른 백엔드도 마찬가지로 "run된 dispose를 관리"할 필요가 없는 구조로 봄.
|
|
**해야 할 일은 딱 하나: 생명주기가 끝난 뒤에 그 대상을 다시 건드리는 시도가
|
|
일어나지 않게 막는 것**(=처리를 그냥 멈춤) 뿐 — 자료구조 자체의 해제는
|
|
가능하면 GC에 맡김.
|
|
|
|
이 원칙 때문에 "값 교체 시 이전 처리를 무르는 것"(아래 `retract`)과 "완전
|
|
소멸 시 정리"는 **하나로 통일** — 후자는 애초에 안 만듦. `base/tween-plan.md`
|
|
와 `base/slot-plan.md`가 쓰던 용어 `cleanup` 표기는 대부분 `retract`로
|
|
갱신됨(이름 변경 근거는 아래) — 잔여 표기 확인은 진행 중, 해당 문서들은
|
|
각자 별도로 정리될 예정.
|
|
|
|
## 함수 안에서 만든 옵저버도 GC 대상이 되어야 함 — 범용 "생명 바인드 유틸" 필요
|
|
|
|
사용자 원 메모: "함수 안에서 옵저버 만들어버린 거, 그것도 gc 대상 되어야
|
|
할 텐데, 이건 아래쪽에 생성하는 실 객체에 유저가 바인드 할 수 있게 하는 약간의
|
|
유틸이 있긴 해야 할듯. connect 트릭 그대로 들고 와서 쓰면 될 것 같고. 옵저버는
|
|
canExecute 같은 람다 함수 하나 달게 해서 Connected 상태 보게 하여 실행 안 될
|
|
수 있게 만들어도 될 듯."
|
|
|
|
즉, 핸들러가 처리 도중 만든 구독/옵저버 클로저는 그 자체로는 아무것도 자동으로
|
|
GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연결해 참조를
|
|
붙잡아두던 "GC 방지 핫팩"(`reference/quad-v1-architecture.md` 참고)과 같은 문제.
|
|
**base가 범용 유틸로 제공할 것: 임의의 클로저/구독을 실제 Roblox 객체의
|
|
생명주기에 바인드하는 도구** — 내부적으로 v1/rbvm이 쓰던 "connect 트릭"(어떤
|
|
신호에든 연결해서 참조를 죽을 때까지만 붙잡아두는 것)을 그대로 재사용. 이
|
|
도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면
|
|
실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결).
|
|
|
|
### `bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션,
|
|
`unbindLifetime`은 2026-08-09 세션 추가, **시그니처는 2026-08-14 다섯
|
|
번째 세션에 `value` 단독으로 최종 정정**, **`canBound`는 2026-08-14 열한
|
|
번째 세션에 별도 진입점으로 재도입** — 아래 "(3)" 절)
|
|
|
|
> **[정정, 2026-08-18 구현 전 QA]** `canBound`의 **판정 방향이 뒤집혀
|
|
> 있었다** — 이름 그대로 "지금 묶을 수 있는가"(참 = 아직 안 묶여 있어서
|
|
> 묶어도 됨)여야 하는데, 문서 전체가 참 = "이미 묶여 있음"으로 쓰고
|
|
> 게이트를 `if canBound(v) then error(...)`로 적어뒀었다. 그대로 구현하면
|
|
> **정상적인 첫 바인드가 전부 에러나고 이중 바인드는 무사통과**한다.
|
|
> 아래 (1)~(3) 절은 전부 정정된 방향(`canBound(v) == not isBoundAlive(v)`,
|
|
> 게이트는 `if not canBound(v) then error(...)`)으로 다시 쓰여 있다.
|
|
> 사용자 판정 원문과 파급 목록은
|
|
> `.claude/qa-request/pre-implementation-qa-round1.md`의 `S-1`.
|
|
|
|
**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/
|
|
`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/
|
|
`canBound`/`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼
|
|
핸들러 작성자가 직접 호출하는 **1급 프리미티브 연산**이라
|
|
`LifetimeHandle.bind(...)`류로 감싸면 안 됨 — `LifetimeHandle.luau` 파일
|
|
안에 있어도 되지만 export는 평평한 함수:
|
|
|
|
```lua
|
|
bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐
|
|
unbindLifetime(value: any): ()
|
|
canBound(value: any): boolean -- "지금 묶어도 되는가" — 참이면 아직 안 묶여 있음(구조적 점유 없음)
|
|
canExecute(value: any): boolean -- "지금 발화해도 되는가" — emit 전파 게이팅
|
|
```
|
|
|
|
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는 `inst`를
|
|
안 받는다 — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음.** 역전 원문과
|
|
오염 경로 추적은 `archive/canexecute-inst-arg-reversed.md`. 요지: **"이 값이
|
|
지금 실행돼도 되는가"는 `value` 자신에게 물어야 하는 질문**이고, 실제로 물을
|
|
수 있다 — `bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value` 쪽
|
|
릴레이션으로 복사해두기 때문(아래 구현). `inst`가 필요한 건 "어느 홀더에
|
|
넣을 것인가"를 정해야 하는 `bindLifetime` 하나뿐.
|
|
|
|
이게 **구조적으로 중요한 이유**: `canExecute`의 실제 호출부는 State 전파
|
|
루프(`emit`)다 — 그 자리엔 `inst`가 없고 있어서도 안 됨(State는 자기가 어느
|
|
Instance에 걸렸는지 모르는 게 정상, 애초에 여러 곳에 걸릴 수 있음). 2-인자
|
|
시그니처는 그 호출부에서 **호출 자체가 불가능**했고, 그래서 지금까지 어느
|
|
문서에도 `canExecute`의 실제 호출부가 코드로 등장한 적이 없었음(서술만 있고
|
|
코드가 없던 이유가 이것). 1-인자로 돌아오면서 호출부가 자연스럽게 성립함
|
|
(아래 "실제 호출부" 절).
|
|
|
|
**`unbindLifetime` 추가 이유(2026-08-09 세션, `dispatch-core-plan.md`의
|
|
"Length/Offset" 논의에서 파생)**: `Dispatch.setLength`(같은 위치에 새
|
|
`State<number>`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함,
|
|
`State<Slot>` 교체가 대표 사례)처럼 **`inst` 전체 생명주기보다 먼저,
|
|
특정 값 하나만 콜백/구독을 끊어야 하는 경우**가 실제로 생김 —
|
|
`bindLifetime`만 있으면 그 호출부가 gchold의 내부 저장 구조(배열이든
|
|
`value`를 키로 쓰는 테이블이든)를 직접 알아야만 특정 항목을 지울 수
|
|
있어서 캡슐화가 깨짐. `unbindLifetime(value)`을 짝으로 추가하면
|
|
호출부는 내부 구조를 몰라도 됨 — 구현이 쉬운 이유도 여기 있음(아래
|
|
스케치처럼 gchold를 `value`를 키로 쓰는 테이블로 두면 `gchold[value] =
|
|
nil` 한 줄). 안 걸려있던 값에 불러도 안전한 no-op(`:Unsubscribe()`류
|
|
기존 관례와 동일).
|
|
|
|
**`unbindLifetime`이 `inst`를 안 받는 것의 실질 이득(2026-08-14 세 번째
|
|
세션)**: 호출부가 "이 값을 *어느* inst에 걸었더라"를 기억할 필요가 없어짐 —
|
|
`base/slot-plan.md`가 `unbindLifetime(slot._mountedInst, observer)`처럼
|
|
`_mountedInst`를 되짚어 넘기던 자리가 전부 `unbindLifetime(observer)`로
|
|
줄고, 그 과정에서 "`_mountedInst`가 이미 갈아치워졌거나 `nil`이면 해제가
|
|
조용히 빗나간다"는 잠재 버그 클래스가 원천 소멸함(값 자신이 자기 홀더를
|
|
알고 있으므로 빗나갈 대상이 없음).
|
|
|
|
base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가
|
|
`BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute`
|
|
관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md`의
|
|
`Relate` 프리미티브 위에 얹힘(2026-08-08 세션, gchold를 `perInstanceState`
|
|
직접 조작 대신 `Relate`로 구현).
|
|
|
|
#### (0) gcconn/gchold는 **Instance 생성 시점**에 만든다 — `bindLifetime`이 아니라
|
|
|
|
**[2026-08-14 다섯 번째 세션 확정, 옛 lazy 생성에서 전환]** 예전 스케치는
|
|
`bindLifetime` 첫 호출에서 gcconn을 lazy 생성했는데, 이건 **`inst`를 키로
|
|
쓰는 모든 `Relate`의 전제를 깨는 구멍**이었음:
|
|
|
|
Roblox의 `Instance` 값은 엔진 객체 자체가 아니라 **엔진 객체를 가리키는
|
|
userdata 포인터**다. Lua 쪽에서 아무도 참조를 안 들고 있으면 그 userdata는
|
|
회수될 수 있고, 나중에 같은 엔진 객체를 `.Parent`/`:GetChildren()` 등으로
|
|
다시 얻으면 **다른 userdata**가 나올 수 있음 — 그러면 이전 userdata를 키로
|
|
저장해둔 `Relate` 항목 전체가 조용히 미아가 됨(`elementOwner`,
|
|
`nameClaims`, Tag 참조카운트 등 `inst`-키 릴레이션 전부 해당). 따라서 quad는
|
|
**자기가 만든 Instance마다 생성 즉시 Lua 쪽 강참조를 하나 심어** 바인딩이
|
|
살아있는 동안 userdata 동일성을 고정한다:
|
|
|
|
```lua
|
|
-- quad-roblox: Instance를 만든 직후 무조건 실행(핸들러/바인딩 유무와 무관)
|
|
local nop = false or function(...) end -- local이라 상수 접힘/인라인 안 됨
|
|
|
|
local gchold = {} -- 이 inst에 매달린 값들의 강참조 홀더
|
|
local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
|
|
nop(gchold, inst) -- 절대 발화 안 함. 클로저가 gchold와 inst를 업밸류로 붙잡는 게 전부
|
|
end)
|
|
gchold[1] = gcconn -- 배열 자리 1번은 gcconn 전용(값들은 해시 자리에)
|
|
|
|
InstData:SetWeak(inst, "gchold", gchold)
|
|
InstData:SetWeak(inst, "gcconn", gcconn)
|
|
```
|
|
|
|
- **`ClassName`은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음**
|
|
(rbvm 패턴 그대로) — 2026-08-13 부분 실측 확인(미발화 + Destroy 시
|
|
`Connected` 즉시 전환), `audit/gcconn-trick-verification.md`.
|
|
- **클로저가 `inst`까지 캡처하는 게 이번 변경의 핵심** — 예전 스케치는
|
|
`gchold`만 캡처했음. `inst`를 캡처해야 위 userdata 동일성이 보장됨.
|
|
- **`InstData`는 `SetWeak`** — gchold/gcconn은 이미 위 클로저↔`gchold[1]`
|
|
상호 참조로 안전하게 살아있으므로, 릴레이션은 약하게만 잡으면 됨.
|
|
**"다른 곳에서 안전하게 유지되는 것은 항상 weak로 잡는다"**가 일반
|
|
규칙(강참조를 중복으로 걸면 실제 수명이 어디서 끝나는지가 흐려져 GC
|
|
버그를 만들기 쉬움) — `base/relate-plan.md`의 상호 순환 경고와 같은 결.
|
|
- **대가: quad가 만든 Instance는 참조를 놓는 것만으로는 회수되지 않고
|
|
반드시 `Destroy`로 회수된다.** 클로저가 `inst`를 잡고, 그 클로저를
|
|
`inst` 자신의 시그널이 잡는 순환이라 Destroy(=엔진이 커넥션을 끊음)가
|
|
유일한 절단면. **실질적으로 새로 생긴 제약은 아님** — 실제 바인딩이
|
|
하나라도 걸리면 그 Observer 클로저가 어차피 `inst`를 캡처해 같은 순환이
|
|
생기므로(예: `dispatch-core-plan.md`의 `StoreBind.process`), 이번
|
|
변경은 "아무것도 안 걸린 Instance"까지 같은 규칙으로 통일한 것뿐.
|
|
|
|
#### (1) `bindLifetime` / `unbindLifetime` / `canBound` / `canExecute`
|
|
|
|
```lua
|
|
-- quad-roblox 실 구현 스케치
|
|
local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐)
|
|
local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움)
|
|
|
|
-- 비공개(export 안 함) — canBound/canExecute가 공유하는 실제 판정.
|
|
-- "이 값이 구조적으로 이미 살아있는 바인딩을 갖고 있는가" 하나만 답한다.
|
|
-- 두 공개 진입점은 이걸 서로 반대 방향으로 감싼다(아래 "(3)" 절).
|
|
local function isBoundAlive(value)
|
|
-- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음.
|
|
-- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움
|
|
-- (gchold가 죽으면 gcconn을 강참조하는 게 없어지므로 weak 항목이 스스로 비워짐).
|
|
local gcconn = BindData:GetWeak(value, "gcconn")
|
|
if gcconn ~= nil and gcconn.Connected then
|
|
return true
|
|
end
|
|
-- (b) 전역 경로: :Subscribe()가 세운 것. Observer/Effect에만 있는 필드.
|
|
if isObserver(value) or isEffect(value) then
|
|
return value.Subscribed == true
|
|
end
|
|
return false
|
|
end
|
|
|
|
function bindLifetime(inst, value)
|
|
-- 이중 바인딩 금지(base/source-state-plan.md) — 게이트는 canBound.
|
|
-- "지금 묶어도 되는가"를 묻는 자리이지 "지금 발화해도 되는가"를 묻는
|
|
-- 자리가 아님(둘의 구분은 아래 "(3)" 절 참고). 못 묶는 경우만 에러.
|
|
if not canBound(value) then
|
|
-- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건
|
|
-- 인덱싱하면 안 됨 — 게이트는 값 타입을 안 가려서 value가 평범한
|
|
-- 클로저일 수도 있음(그 경우 필드 접근 자체가 에러).
|
|
local isGlobal = isObserver(value) or isEffect(value)
|
|
if isGlobal then isGlobal = value.Subscribed == true end
|
|
error(if isGlobal
|
|
then "이미 :Subscribe()로 전역 바인딩된 값"
|
|
else "이미 다른 Instance에 바인딩된 값")
|
|
end
|
|
|
|
local gchold = InstData:GetWeak(inst, "gchold")
|
|
gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1)
|
|
-- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2).
|
|
-- 둘 다 weak — gchold는 위 (0) 클로저가, gcconn은 gchold[1]이 이미 안전히 붙잡고 있음.
|
|
BindData:SetWeak(value, "gchold", gchold)
|
|
BindData:SetWeak(value, "gcconn", InstData:GetWeak(inst, "gcconn"))
|
|
end
|
|
|
|
function unbindLifetime(value)
|
|
local gchold = BindData:GetWeak(value, "gchold")
|
|
if gchold then
|
|
gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제
|
|
end
|
|
BindData:SetWeak(value, "gchold", nil)
|
|
BindData:SetWeak(value, "gcconn", nil)
|
|
end
|
|
|
|
-- "지금 묶어도 되는가" — 참이면 아직 아무 데도 안 묶여 있다는 뜻.
|
|
-- bindLifetime의 이중 바인딩 가드, Observer:Subscribe()의 이중 등록 가드,
|
|
-- Ref가 두 자리에 동시에 놓이는 걸 막는 가드(`base/ref-plan.md`)처럼
|
|
-- "이 값을 지금 묶어도 되는가"를 묻는 자리는 전부 이걸 씀. 호출부는
|
|
-- 항상 `if not canBound(v) then error(...) end` 모양이 된다.
|
|
function canBound(value)
|
|
return not isBoundAlive(value)
|
|
end
|
|
|
|
-- "지금 발화해도 되는가" — State emit 전파 루프가 구독자를 게이팅할
|
|
-- 때만 씀(아래 "(4) 실제 호출부" 절). 같은 isBoundAlive를 공유하지만
|
|
-- canBound와는 **반대 방향**(canBound(v) == not canExecute(v))이고,
|
|
-- 호출부의 질문 자체도 다르므로 이름을 분리해둔다.
|
|
function canExecute(value)
|
|
return isBoundAlive(value)
|
|
end
|
|
```
|
|
|
|
#### (1-1) ✅ [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`] 첫 인자는 **항상 물리 Instance**다
|
|
|
|
여기 있던 절(4라운드 `D-56`)은 *"`Dispatch.setLength`의 `ownerKey`가 Slot일 수
|
|
있으니 백엔드의 `bindLifetime`이 비-Instance 첫 인자를 핸들링하고,
|
|
`isBoundAlive`에 세 번째 분기를 둬야 한다"*였다. **5라운드에서 뒤집혔다** —
|
|
`setLength`가 **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 따로 받도록**
|
|
바뀌면서, 앵커는 언제나 물리 target이 된다(모든 호출부가 이미 그 값을 알고
|
|
있다). 그래서:
|
|
|
|
- **`bindLifetime`/`unbindLifetime`/`isBoundAlive`는 예전처럼 물리 Instance만
|
|
상대한다** — 백엔드에 추가 요구사항이 없고, `isBoundAlive`의 **세 번째
|
|
분기도 필요 없다**(형태 미정인 채 열려 있던 항목이 이걸로 닫혔다).
|
|
- 근거와 트레이싱은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절
|
|
바로 뒤 문단, 역전 전 원문은 `archive/bindlifetime-slot-owner-reversed.md`.
|
|
|
|
**`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부):
|
|
|
|
1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]`
|
|
강참조가 그것.
|
|
2. **`value`는 `inst`가 살아있는지 스스로 확인할 방법을 갖는다** — `BindData`에
|
|
복사된 gcconn 참조가 그것. `isBoundAlive`(따라서 `canBound`/`canExecute`)가
|
|
`inst` 없이 성립하는 이유.
|
|
|
|
**`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로
|
|
전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도
|
|
않음**. 옛 스케치가 `bindLifetime` 안에서 `value.Subscribed = true`를
|
|
세팅하던 것이 이 문서의 오염 지점이었고, 그게 "`canExecute`가 `inst`를
|
|
받아야 한다"는 잘못된 귀결까지 끌고 왔음(상세는
|
|
`archive/canexecute-inst-arg-reversed.md`).
|
|
|
|
#### (2) 전역 경로 — `:Subscribe()`/`:Unsubscribe()`
|
|
|
|
`inst`에 안 묶이는(모듈 최상위 디버그 print류) Observer/Effect 전용. 상세
|
|
규칙과 경고는 `base/source-state-plan.md`의 "`:Subscribe()`/`:Unsubscribe()`"
|
|
절이 소스이고, 여기선 `canBound`가 보는 상태만 못박음:
|
|
|
|
```lua
|
|
local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적)
|
|
|
|
function Observer:Subscribe()
|
|
if not canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유)
|
|
error(if self.Subscribed
|
|
then "이미 :Subscribe()된 값"
|
|
else "이미 Instance에 바인딩된 값")
|
|
end
|
|
self.Subscribed = true
|
|
Subscribed[self] = true
|
|
return self
|
|
end
|
|
|
|
function Observer:Unsubscribe()
|
|
Subscribed[self] = nil
|
|
self.Subscribed = false
|
|
return self
|
|
end
|
|
```
|
|
|
|
`.Subscribed` 필드와 `Subscribed` 테이블이 **둘 다** 있는 이유: 테이블은
|
|
강참조 루트(생존 보장), 필드는 `canBound`/`canExecute`가 매번 읽는 O(1)
|
|
경로 + 에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이
|
|
쓰고 같이 지우는 한 세트(`:Unsubscribe()`가 필드만 내리고 테이블을 안
|
|
비우면 반쪽짜리 해제가 됨 — `base/source-state-plan.md`에 이미 확정된 규칙
|
|
그대로).
|
|
|
|
#### (3) `canBound` vs `canExecute` — 문맥이 달라 다시 갈라짐
|
|
|
|
**[2026-08-14 열한 번째 세션, 다섯 번째 세션의 "canBound 폐기" 결정을
|
|
부분적으로 되짚음]** 원래 폐기 서사·오염 경로 추적은
|
|
`archive/canexecute-inst-arg-reversed.md`에 그대로 있음(그 문서가 고친
|
|
버그 — 2-인자 `canExecute(inst,value)`가 오염이었다는 것, `unbindLifetime`/
|
|
`canExecute`가 `inst`를 안 받아야 한다는 것 — 은 전부 그대로 유효, 이번에
|
|
되짚는 건 "판정을 하나의 이름으로 합칠지 두 이름으로 나눌지"뿐).
|
|
|
|
**왜 다시 나눴나(사용자 판단, `question.md` 0-W 논의 중 제기)**:
|
|
`canExecute`라는 이름 하나가 실제로는 서로 다른 두 호출 맥락을 겸하고
|
|
있었음:
|
|
|
|
1. **bound 문맥 — "이 값이 이미 어딘가에 유효하게 묶여 있는가"**(구조적
|
|
점유 여부를 묻는 질문). `bindLifetime`의 이중 바인딩 가드,
|
|
`Observer:Subscribe()`의 이중 등록 가드, 그리고 `Ref`가 두 자리에
|
|
동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md`
|
|
"이중 배치 방지" 절)가 전부 이 질문만 물음 — 이 값들은 emit 전파에
|
|
참여조차 안 하는 경우도 있음(`Ref`가 그 예).
|
|
2. **execute 문맥 — "지금 이 구독자가 발화해도 되는가"**. State emit
|
|
전파 루프가 매 발화마다 각 구독자에게만 묻는 질문(아래 "(4)" 절) —
|
|
`Effect`/`Observer`처럼 실제로 콜백을 실행하는 값에만 의미가 있음.
|
|
|
|
**[정정, 2026-08-18 구현 전 QA] 두 판정값은 같은 게 아니라 서로의
|
|
부정이다** — `canBound(v) == not isBoundAlive(v)`, `canExecute(v) ==
|
|
isBoundAlive(v)`. 열한 번째 세션은 "판정 로직도 같고 값도 항상 같은데
|
|
호출부의 질문만 다르다"를 이름 분리의 근거로 적었는데, 그건 `canBound`를
|
|
"이미 묶여 있는가"로 잘못 읽은 결과였다. 이름 그대로 읽으면 두 질문은
|
|
**반대 방향**이고, 공유하는 건 판정 **로직**(`isBoundAlive`) 하나뿐이다.
|
|
**부정 관계라는 사실은 이름 분리의 명분을 오히려 강화한다** — 같은 값을
|
|
두 이름으로 부르는 게 아니라, 서로 다른 방향을 묻는 두 predicate이기
|
|
때문에 호출부가 `not`을 붙이는지 여부로 의도가 드러난다.
|
|
|
|
여전히 유효한 것 — **호출부가 왜 묻는지가 서로 다르다**: `Ref`처럼
|
|
발화라는 개념 자체가 없는 값에게 "발화해도 되는가"(`canExecute`)를 묻는
|
|
건 개념이 안 맞고, 나중에 "구조적으로는 묶여 있지만 일시적으로 발화만
|
|
멈춘" 상태가 생기면(지금은 없음) 둘의 관계가 단순 부정에서 더 벌어질
|
|
여지도 있다.
|
|
|
|
**해법 — 이름은 둘, 판정 로직은 하나(사용자 제안).** 실제 gcconn/
|
|
`.Subscribed` 체크는 비공개 헬퍼 `isBoundAlive(value)`(위 (1) 코드
|
|
블록) 하나에만 있고, `canBound`/`canExecute`는 그 헬퍼를 각각 부정해서/
|
|
그대로 감싸는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨.
|
|
**바뀐 호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`의
|
|
가드(위 (2))는 이제 `canBound`를 씀 — 형태는 항상 **`if not canBound(v)
|
|
then error(...) end`**(못 묶는 경우에만 에러). **안 바뀐 호출부**: State
|
|
전파 루프(아래 "(4)")만 여전히 `canExecute`를 쓰고, 거기선 부정 없이
|
|
그대로 씀.
|
|
|
|
부수 효과: **"바인딩이 죽은 뒤의 재사용은 허용"** — `inst`가
|
|
Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라
|
|
게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는
|
|
게 이 게이트의 의도.
|
|
|
|
#### (4) 실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다
|
|
|
|
`canExecute`가 "어디서 불리는가"는 지금까지 어느 문서에도 코드로 없었음(위
|
|
정정 배너 참고). 확정된 위치는 **State의 전파 루프**:
|
|
|
|
- State는 자기 구독자(Observer의 emit 클로저)를 **weak로** 담는다 — 살려두는
|
|
책임은 State가 아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에
|
|
있고, 어디에도 안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐.
|
|
- 발화 시 각 구독자에 대해 `canExecute(observer)`를 확인하고, 거짓이면
|
|
**그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를 건드리는 시도가
|
|
일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 실제 구현 지점.
|
|
|
|
**`state:Observer(fn)`의 "등록 즉시 1회 실행"은 이 게이팅과 무관**하다 —
|
|
그건 Observer 생성자 자체의 계약이라 `bindLifetime` 이전에 동기적으로
|
|
일어나고(그 시점엔 `canExecute`가 당연히 거짓), 게이팅 대상은 **그 이후의
|
|
재실행**뿐. `base/slot-plan.md`/`base/dispatch-core-plan.md`가 이미 같은
|
|
내용을 주석으로 달아둔 것과 같음.
|
|
|
|
**Instance당 gcconn/gchold는 하나로 공유** — `bindLifetime`을 여러 값에
|
|
대해 여러 번 불러도 같은 `inst`면 같은 `gcconn`/`gchold`를 재사용(위 (0)에서
|
|
Instance 생성 시 한 번만 만들어지고, 이후는 `InstData:GetWeak`으로 바로
|
|
찾음). 자세한 내부 구조는 `base/relate-plan.md`.
|
|
|
|
**실측 필요(M0/M2)**: `canExecute`가 매 발화마다 `BindData:GetWeak(value,
|
|
"gcconn")`(weak table 2단 조회)를 하는 비용이 실사용에서 문제되는지는
|
|
quad-roblox 구현 단계에서 실측 확인 대상 — 문제가 되면 gcconn을 `value`의
|
|
직접 필드로 내리는 선택지가 있음(옛 초안이 `self.Connection`으로 스케치했던
|
|
모양). 지금 `Relate` 쪽으로 둔 이유는 "Observer 값 자체에 부작용을 안
|
|
남기고 외부 weak 인덱싱을 선호"라는 기존 사용자 방침(`base/source-state-plan.md`의
|
|
"`state:Observer(fn)`" 절 구현 노트)이고, 성능 근거가 나오면 뒤집어도 되는
|
|
순수 구현 세부.
|
|
|
|
이건 `base/dispatch-core-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate`
|
|
직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"
|
|
(`Relate:SetStrong`/`:SetWeak`), 다른 하나는 "언제까지 실행되어도 되는지"
|
|
(`bindLifetime` + `canBound`/`canExecute`)를 다룸. 후자는 내부적으로 전자가 제공하는
|
|
같은 `Relate` 프리미티브 위에 얹혀 구현됨(위 절) — 별도 저장 메커니즘을
|
|
새로 만든 게 아니라 `Relate` 하나를 두 용도로 재사용. 둘 다 base가 제공하는
|
|
범용 유틸로 확정.
|
|
|
|
**교차검증(2026-08-04 4차 라운드)**: 사용자가 공유해준 실제 참고 코드
|
|
(`.claude/initreq/artworks/EventDrivenProgramming/`, PA님 작성)는 GC-native가
|
|
아니라 전부 수동 `:unsubscribe()`/`:disconnect()`로 관리됨 — rbvm 기반
|
|
GC-native 원칙과 반대 선택이라 재확인했으나 **GC-native 유지로 확정**(지금까지
|
|
명시적 dispose가 꼭 필요할 만큼 큰 자원을 다루는 실제 사례가 없었음). **막다른
|
|
길은 아님을 기록**: rbvm처럼 관계를 양쪽 다 weak-keyed로 두고 모든 걸 connection
|
|
람다에 담아두는 방식이면, 나중에 GC만으로 부족한 케이스가 실제로 생겨도 그
|
|
connection을 얻어 `disconnect()`하는 명시적 dispose 경로를 추가로 얹는 게
|
|
가능한 디자인 — 필요성이 드러나면 그때 얹을 하이브리드 여지로만 남겨둠.
|
|
|
|
**재사용 사례(2026-08-04 2차 라운드)**: Store/State의 무효화(invalidate)
|
|
신호를 받는 리스너 클로저도 정확히 이 유틸로 등록됨 — `base/
|
|
store-plan.md`가 예전에 "state 옵저빙 결과로 slot을 조작할 때 생존
|
|
여부를 어떻게 확인할지" 미해결로 남겨뒀던 문제가, 사실은 새 메커니즘이
|
|
필요한 게 아니라 이 canExecute 게이트를 그대로 적용하면 되는 사례였음(별도
|
|
`isInit` 분기 불필요). 상세는 `base/source-state-plan.md`의 "Slot 생존 확인" 절 참고.
|
|
|
|
## 2026-08-04 검증 라운드에서 보강된 내용
|
|
|
|
**`Connected` 체크는 rbvm 패턴을 그대로 베끼는 게 아니라 base가 인터페이스로만
|
|
내보내는 것.** Roblox는 `RBXScriptConnection`에 이미 `Connected`가 존재하고
|
|
Destroy 시 모든 커넥션을 즉시 끊어주지만, 다른 엔진에서도 라이프사이클을
|
|
확인할 수 있어야 하므로 base는 "이 바인드가 아직 유효한가"를 묻는 람다/인터페이스만
|
|
정의하고, quad-roblox가 그 구현을 Roblox의 실제 `Connected`로 채워넣는다(구현
|
|
주입 방식은 아래 "base 유틸은 인터페이스, 구현은 백엔드 팩토리" 절 참고). 이게
|
|
필요한 이유: rbvm처럼 GC 트릭으로 라이프사이클을 연결하면 GC가 즉발이 아니라서
|
|
중간에 죽은 참조가 남아있을 수 있고, 그 시점에 store에 새 값이 들어오면 죽은
|
|
대상에 처리를 시도하다 터질 수 있음 — 그래서 처리 직전에 유효성을 확인.
|
|
|
|
**`Destroying` 훅은 생각보다 덜 중요할 수 있음.** rbvm의 GC-네이티브 무효화
|
|
방식(자료구조를 직접 건드리지 않고 네이티브 GC에 후처리를 위임)이 성능상
|
|
유리해서, `Destroying` 훅에 명시적으로 의존하는 경로는 실제로는 거의 필요
|
|
없을 가능성이 큼 — 확정된 방향(Destroying 하나로 통일)은 유지하되, 실제
|
|
구현에서 이 훅을 쓰는 지점이 예상보다 적을 수 있다는 점을 열어둘 것.
|
|
|
|
**즉시(eager) 정리 예외 두 가지(작고 유계한 포인터, 네임스페이스 dispose)는
|
|
quad에는 거의 해당 안 될 가능성이 큼.** rbvm은 이미 존재하는 real DOM 위에
|
|
가상 계층을 얹는 구조라 "가상 계층이 필요 없어지면 지운다"는 문제가 있지만,
|
|
quad는 자신이 만든 instance를 항상 끝까지 들고 있어서 이런 종류의 즉시 정리
|
|
자체가 필요 없을 가능성이 높음 — 실제 구현 단계에서 필요성이 확인되면 그때
|
|
추가.
|
|
|
|
**retract는 Destroy 시점에 필요 없는 이유가 엔진 레벨에서 한 번 더 보강됨.**
|
|
Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 알아서
|
|
정리해준다 — 라이브러리가 따로 처리할 필요가 없음. Roblox 이외의 엔진에서
|
|
이런 정리가 필요하다면 그건 그 엔진의 `quad-X` 서브패키지가 책임질 문제(base
|
|
관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 **`Effect`(그리고 그 슈가
|
|
`OnDestroyed`)를 쓰면 되는 구조**라, 라이브러리가 강제로 제공할 필요도 없음.
|
|
**[정정, 2026-08-20 구현 전 QA 4라운드 `LP-4`]** 옛 서술은 여기 정상 경로를
|
|
`[Event "Destroying"]`을 직접 바인드하는 것으로 적었는데, 그건 사용자가 엔진
|
|
이벤트를 손으로 다루라는 뜻이 되어 quad가 이미 제공하는 프리미티브를 우회하는
|
|
안내였다 — 사용자 판정: *"Effect 임. 그리고 그 슈거인 OnDestroyed 존재"*.
|
|
`Effect(fn)`이 반환하는 cleanup이 leaf 사망 시 정확히 1회 불린다는 계약
|
|
(`base/effect-plan.md`)이 정확히 이 용도이고, `OnDestroyed(fn)`은 그걸 감싼
|
|
순수 팩토리다(`base/lifecycle-hooks-plan.md`). `[Event "Destroying"]`을 직접
|
|
바인드하는 것도 물론 막히진 않지만 권장 경로가 아니다.
|
|
|
|
## 이름: `cleanup` → `retract`
|
|
|
|
"cleanup"이라는 이름은 부적절하다는 사용자 피드백(완전 소멸 정리로 오인되기
|
|
쉬움) — 실제 의미는 "이전에 적용한 처리를 무른다/멈춘다"이므로 **`retract`**
|
|
로 통일. (`revert`, `rescind`도 검토했으나 `retract`가 "이전에 취한 조치를
|
|
철회한다"는 의미로 가장 정확 — `process`/`retract` 쌍으로 자연스럽게 대구를
|
|
이룸.) 대부분의 문서에서 이 이름으로 갱신됨.
|
|
|
|
**[확인 완료, 2026-08-13,
|
|
`session/2026-08-13-06-commit-audit-dispatch-redesign-bugs.md`]
|
|
`base/effect-plan.md`가 쓰는 용어 `cleanup`은 잔여 stale이 아니라 의도된
|
|
별개 개념** — 매 감사마다 재지적되므로 여기
|
|
못박아 둠. 두 층위가 다름:
|
|
- **`retract`**: Handler 계약의 것. `process`가 반환하는 클로저로, quad
|
|
**내부 배관**이 "이전 처리를 무른다".
|
|
- **`cleanup`**: `Effect(fn)`에서 **사용자가 작성한 `fn`이 반환하는 콜백**.
|
|
React `useEffect`의 그것과 동형이고, 사용자 API 표면의 어휘라 `retract`로
|
|
통일할 대상이 아님(오히려 통일하면 React 배경 사용자에게 더 낯설어짐).
|
|
|
|
즉 "cleanup 잔여 확인"은 `effect-plan.md`에 대해서는 **끝난 것**으로 봐도 됨.
|
|
|
|
**[2026-08-13 다섯 번째 세션] `retract`는 더 이상 Handler의 *필드*가
|
|
아니라 `process`가 반환하는 클로저의 *역할 이름*이다.** 개념/이름
|
|
자체는 그대로 유효하고(이 절의 결론은 안 바뀜), 다만 코드에서
|
|
`handler.retract(...)`를 찾으면 안 됨 — `local retractor =
|
|
handler.process(inst,k,v,index)` 형태로 받아서 `Dispatch`가 `chains`에
|
|
보관했다가 부름(`base/dispatch-core-plan.md` "핸들러 계약"/"Dispatch 체인"
|
|
절). 이 문서가 계속 쓰는 "retract 시점"/"retract가 불린다"는 표현은
|
|
전부 그 클로저가 호출되는 시점을 가리킴.
|