`qa-request/pre-implementation-handtrace-round7.md`의 `H-55`~`H-106`을
사용자와 대화형으로 처리하고 `base/`에 전량 반영했다. 결정의 소스는
`-followup.md`(개수·개별 항목은 여기서 세지 않는다).
## 처분
확정 39 / 무효·소멸 4(`H-73`~`H-76`) / 기각 1(`H-77`) / 범위 축소 2 /
다른 항목으로 흡수 6.
**부수로 `question.md` 최우선 절이 비었다** — 중간 State GC는 `_hold`
불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 `store:Of<<T>>(name)`
하나로 닫혔다. **M2 착수를 막는 항목이 없다.**
## 구조가 바뀐 것
- `Ref`가 `Epoch`를 만족(`.Revision` + `EpochBrand`) — 포탈 캐치업 비대칭과
같은 `Ref` 중복 dep이 같이 닫힘
- `Weak*` 등록 표면 신설(`Ref:WeakCallback` / `Observer:WeakSubscribe`) —
Weak 쪽이 프리미티브고 강한 쪽이 "GC 킵"을 얹은 것
- `Effect`: dep 등록이 생성자 한 곳으로, 강한 주인은 `_deps` 하나,
억제는 사적 `Blocker`, `bindLifetime`은 핸들 하나에만 적용.
`:Rerun()` 정의 신설(재진입은 지연 재실행), `_installed` 신설
- 전파 루프 의사코드 확정 — 구독자 집합의 원소는 Observer **값**이고
**자식 State 노드는 `canExecute`를 안 탄다**(그대로 짜면 파생 State
아래가 전부 침묵했다)
- `rawInvalid` → `cacheTargetCount`/`cacheCurrCount` 카운터 쌍
- `recompute` 재진입 차단 + `invalidAfter` 되감기, `gatedRecompute`가
인덱스를 캡처하지 않고 토큰으로 조회
- `emit(commit) -> boolean`, `EpochMap:Peek` — 정책이 흡수 집합을
버리지도 읽지도 못하던 것을 닫음
- error 계약(`level` 이분, 메시지는 영어)과 예외 계약(`pcall`로 안 감쌈)을
`architecture.md`에 신설
## Store — 같은 날 재설계하고 철회했다
`H-75`/`H-76`으로 `WrapStore`/`ProcessStoreType`이 폐기되자 그 자리를
"`store.key`는 값, `store:Of(k)`가 프리미티브"로 채웠다가 **같은 날
철회**했다(`archive/store-value-field-redesign-withdrawn.md`). 살아남은 건
**명시적 초기화** 하나다. 최종형은 타입 인자에 `Source<T>`를 직접 쓰고
`store.key`는 평범한 레코드 필드이며 **타입 함수를 안 쓴다**.
철회 이유 중 하나가 원칙으로 승격됐다 — **"타입 함수는 타입이 못 잡는
문제를 에러로 띄우는 정도 이상으로 가지 않는다"**(`typing-limits.md` §0).
`index<>`/`keyof<>`도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다.
## 툴체인
**`luau` CLI가 심볼릭 링크를 못 탄다**(디렉토리·파일 둘 다)는 것이 최소
재현으로 밝혀졌다 — pesde 워크스페이스 링크가 전부 심볼릭이라 스모크 2개가
안 돌았고 `luau-analyze`는 **조용히 통과**했다("거짓 클린"). `scripts/relink.sh`
+ `scripts/test.sh` 신설, 이제 스모크 셋 전부 PASS.
## 검증
`/code-review high` 2회(12건 + 14건)와 감사 8패스(12/6/6/4/13/3/6/0건)를
각도를 바꿔가며 돌렸고 전부 반영했다 — 마지막 패스가 무발견으로 수렴.
각 패스의 각도와 대표 발견은 `-followup.md`의 검증 절이 소스.
`doc-check.py` ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
759 lines
53 KiB
Markdown
759 lines
53 KiB
Markdown
# Effect — 설치 + 확정 정리, `state` 있으면 Observer를 감싸 재실행도 지원
|
|
|
|
**상태**: base — `research/additional-primitives-plan.md`(다른 프레임워크
|
|
대비 갭 분석)에서 갈라져 나온 확정 프리미티브. `base/blocker-plan.md`(같은
|
|
조사에서 나온 다른 확정 프리미티브)와는 서로 무관 — Store/State 작업이나
|
|
Ref/PreRef와 파생 관계는 아니라 별도 파일로 둔다(2026-08-07 문서 정리에서
|
|
한 파일로 합쳤던 걸 다시 분리). 단 `state` 인자를 받는 형태는 내부적으로
|
|
Observer를 조합해서 만들어짐(아래 참고, 2026-08-07 여섯 번째 세션 확정) —
|
|
"Observer와 무관한 완전 독립 프리미티브"였던 이전 서술은 정정됨.
|
|
|
|
**Effect와 Observer의 관계 확정(2026-08-07 여섯 번째 세션)**: 별개의
|
|
독립 프리미티브이되, `state`를 받는 형태의 Effect는 내부적으로 Observer를
|
|
**조합(compose)**해서 만들어짐 — Ref/PreRef처럼 브랜드 태그만 다른 재사용이
|
|
아니라, Observer(재실행 신호) 위에 자동 cleanup 배선을 얹은 한 단계 위
|
|
계층. **자유 함수인 이유는 여전히 유효**: `state` 없이도 성립하는
|
|
mount/unmount 전용 유스케이스가 있고, 실제 leaf 생명주기 바인딩은 (Observer와
|
|
마찬가지로) children 배열 위치에 거는 것이라 `state`가 그 바인딩을 소유하지
|
|
않음 — Roblox엔 `task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이
|
|
흔하고, Luau 테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는
|
|
순간"을 아는 유일한 방법은 `Instance.Destroying`류 명시적 신호뿐 — 이런
|
|
케이스(타이머 시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive로
|
|
합의됨.
|
|
|
|
```
|
|
Effect(fn, ...deps) -> EffectHandle
|
|
```
|
|
**[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`(의존성 하나)였다 —
|
|
아래 "`Effect(fn, ...deps)`" 절이 소스.
|
|
|
|
**`state` 생략 시**: `fn()`을 즉시 1회 실행, 리턴값(`nil | () -> ()`)은
|
|
이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음
|
|
(mount/unmount 전용, React `useEffect(fn, [])`와 동형).
|
|
|
|
**dep 지정 시(2026-08-07 여섯 번째 세션 확정, 2026-08-21 `C-6`으로 N-deps 확장)**:
|
|
Effect는 내부적으로 각 dep에 구독을 거는 걸로 구현 — State/Source면
|
|
`state:Observer(...)`, `Ref`면 `:Callback`.
|
|
**⚠️ [표기 정정, 2026-08-24 6라운드 `H-14`] 여기 원래 *"`fn`은 포지셔널 인자로
|
|
`state`를 받고(`fn(state)`)"*라고 적혀 있었는데 그건 폐기된 단수 시절
|
|
표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> ...(() -> ())`**이고
|
|
(**[2026-08-25 `H-95`]** 가변 반환 팩 — 옛 `-> (() -> ())?`는 콜백이 "선언보다
|
|
적게 반환"할 때 strict에서 막혀 `Effect(function() print("x") end, s)` 같은
|
|
정상 용례가 안 통과했다)
|
|
**deps는 `fn`에 안 넘어간다**(아래 "`Effect(fn, ...deps)`" 절이 소스,
|
|
dep 값은 사용자가 클로저로 직접 읽는다).
|
|
같이 붙어 있던 *"이 `fn(state)`가 lazy `State` 핸들을 받는다는 전제는 확정
|
|
유지"*(2026-08-13 13차 세션)도 그 표기에 묶인 서술이라 함께 폐기된다 —
|
|
다만 그 항목이 실제로 확인한 것(**`Effect`는 재귀 제네릭 타입 누수와
|
|
애초에 무관**하다, 자유 함수라 "재귀 타입의 필드 + 로컬 제네릭" 조건에 안
|
|
걸림 — `base/typing-limits.md` 1번의 영향 범위 표)은 시그니처와 무관하게
|
|
그대로 유효하다.
|
|
Observer가 이제 등록 즉시 1회 실행되므로(아래 Observer 절 참고) 그 첫
|
|
실행이 "설치"를 겸함. 이후 `state`가 무효화될 때마다 **직전 `fn` 호출이
|
|
리턴한 cleanup을 먼저 호출한 뒤 `fn`을 재호출**, 그리고 Effect가 바인드된
|
|
leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로 React
|
|
`useEffect(fn, [dep])`와 동형(설치+재실행 사이/최종 cleanup 전부 같은
|
|
반환 계약 하나로 처리).
|
|
|
|
- **🔄 [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-6`] "다수 의존성은 `:With`로
|
|
묶어서 넘길 것 / trailing args sugar는 안 만듦"(2026-08-11 세션) — 뒤집혔다.**
|
|
지금은 `Effect(fn, ...deps)`가 의존성을 **여러 개 직접 받고 각각에 구독을
|
|
건다**(아래 "`Effect(fn, ...deps)`" 절이 소스). 옛 근거("의존성이 둘 이상이면
|
|
합칠 새 노드가 실제로 필요하니 그 비용을 sugar로 감추지 말자")가 무너진
|
|
이유는 둘 — (1) **`Ref`는 State가 아니라 `:With`로 합칠 수가 없어서**, 그
|
|
모델에선 `Ref`가 Effect의 의존성이 될 방법이 **아예 없었다**(실제 갭),
|
|
(2) 각 의존성에 구독을 따로 걸면 **합치는 노드 자체가 안 생긴다** — 감출
|
|
비용이 애초에 없다. 옛 서술이 인용하던
|
|
`source-state-plan.md`의 "`:Compute(fn, ...)`" 선례는 이제 **따르는 쪽**의
|
|
근거가 됐다(인자 모양을 그 관용구 그대로 맞춤).
|
|
- **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** —
|
|
`Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn`을
|
|
만들어 반환하는 패턴(**[2026-08-24 표기 정정]** 그 `fn`은 `fn(state)`가
|
|
아니라 `fn(self)`다 — 위 배너), `Modifier`의 `Boldify(10)` 커링 관용구(`modifier-plan.md`
|
|
8번)와 같은 결. `state:Observer(fn)`도 동일하게 커링 스타일을 권장 대상으로
|
|
같이 문서화(아래 Observer 절 참고) — 모듈화가 필요하면 둘 다 이 패턴을 쓸 것.
|
|
- **재실행이 필요 없는 케이스와 혼동하지 말 것**: 값 변화와 무관하게 설치+최종
|
|
정리만 필요하면 `state` 없이 `Effect(fn)`을 씀 — `state`를 굳이 넘겨서
|
|
재실행을 유발할 필요 없음.
|
|
|
|
children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 그대로 재사용(그
|
|
leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백 호출). 비용은
|
|
leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다
|
|
비쌈) — 필요할 때만 쓰는 걸로 충분.
|
|
|
|
### ⭐⭐ 그 `Destroying` 바인딩을 **누가 거는가** (2026-08-24 확정, 6라운드 손 트레이싱 `H-11`)
|
|
|
|
**갭이 실재했다 — cleanup을 발화시키는 코드가 코퍼스 어디에도 없었다.**
|
|
세 문서가 `Destroying`을 **전제로만** 쓰고 아무도 연결하지 않았다:
|
|
`base/lifecycle-pattern.md`는 *"인스턴스 라이프사이클 훅 지점은 `Destroying`
|
|
하나로 통일"*이라 못박고 `LP-2`가 **`Effect`가 그 훅을 쓰는 유일한 소비자**라
|
|
확정했으며, 위 문단은 비용까지 적어뒀다. 그런데 leaf가 실제로 붙는 유일한
|
|
경로는 `ObserverEffectLeafHandler.process`의 `bindLifetime(inst, v)` 한 줄이고,
|
|
`bindLifetime`의 실 구현 스케치는 `gchold[value] = true` + `BindData`에
|
|
gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그걸 부를
|
|
주체도 없었다.** 그래서 leaf가 죽으면 `canExecute`가 거짓이 되어 *"앞으로
|
|
발화하지 마라"*는 성립하지만 **cleanup은 영원히 안 불린다.**
|
|
|
|
**파급이 국소적이지 않았다** — 이 한 줄이 없으면 `slot._detachCleanup`
|
|
(`Detach`로 홀드된 요소를 파괴하는 **유일한** 경로,
|
|
`base/slot-plan.md`가 *"GC 폴백이 아예 없으므로 명시적 정리 경로가 필수"*라고
|
|
적어둔 그것)과 `OnDestroyed`(`base/lifecycle-hooks-plan.md`, `Effect` 위의 순수
|
|
슈가라 통째로 무동작)가 같이 죽는다.
|
|
|
|
**확정(사용자, 2026-08-24)**:
|
|
|
|
1. **`bindLifetime`/`unbindLifetime`이 `isEffect(value)`를 보고 직접 처리한다.**
|
|
**[2026-08-24 재결정, `/code-review high` 지적]** 여기 한때 *"`EffectHandle`
|
|
쪽이 자기 `bindLifetime` 직후에 건다"*고 적었는데 **그 호출부가 실재하지
|
|
않는다** — 핸들은 남이 자기를 `bindLifetime`하는 걸 관측할 수 없다. 게다가
|
|
`Effect`가 바인드되는 경로는 **둘**이고(`ObserverEffectLeafHandler.process`의
|
|
children 배열 leaf, 그리고 `activateList`가 `_detachCleanup`을 직접 바인드하는
|
|
내부 경로 — `base/slot-plan.md`) **`Destroying`이 가장 절실한 쪽이 후자**라,
|
|
핸들러 층에 분기를 둬도 안 덮인다.
|
|
**사용자 판단(2026-08-24)**: *"`Destroying` 자체가 엔진이 아는 요소이기
|
|
때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기 때문에,
|
|
bind 상 옵져버 목록을 가져와 자신이 재귀하고, `bindLifetime` 이 처리하는게
|
|
나아보입니다."* — `bindLifetime`은 이미 `handle._observers`로 cascade하며
|
|
값을 들여다보는 자리이므로, 그 옆에 `Destroying` 배선을 두는 게 새 층을
|
|
만드는 것보다 낫다.
|
|
- **한때 근거로 든 *"게이트는 값 타입을 안 가린다"*는 이 자리에 안 맞는
|
|
인용이었다** — `base/source-state-plan.md`의 그 절이 말하는 건
|
|
**`canBound` 판정**이 `:Subscribe()`/`bindLifetime` 두 진입점에서 같다는
|
|
것이지, `bindLifetime`의 **부수 배선**이 값 종류를 못 본다는 게 아니다.
|
|
실제로 그 함수는 이미 `Effect`면 내부 Observer로 cascade한다.
|
|
2. **`unbindLifetime`은 cleanup을 부르지 않는다.** `Destroying` 커넥션을 끊고
|
|
**`Ref` 콜백도 같이 해제**하되(아래 `H-7` 절과 대칭), cleanup은 그대로
|
|
남긴다 — `destroySlotTree`가 `_detachCleanup`을 `unbindLifetime`하며 달아둔
|
|
주석(*"이미 손으로 비웠으니 Effect는 할 일 없음"*)과 `E-11`(leaf 바인딩엔
|
|
`:Unsubscribe()`가 안 먹는다)이 그 전제 위에 서 있다. **이 계약을 명시한다** —
|
|
지금까진 어느 쪽도 안 적혀 있었다.
|
|
- **bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을
|
|
떼고 재마운트의 `bindLifetime`이 다시 건다(`_observers` cascade와 같은 결).
|
|
3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전
|
|
cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와
|
|
`Rerun`이 같은 자리를 읽게 된다.
|
|
|
|
**⭐⭐ [2026-08-25 재설계, 7라운드 `H-58`/`H-59`/`H-64`/`H-70`] dep 등록은
|
|
`bindLifetime`이 아니라 생성자에서 한 번만 한다.** 옛 의사코드는 `Ref` 콜백을
|
|
`_bindDestroying`에서 (재)등록했는데, `ref-plan.md`가 확정한 *"등록 즉시 그
|
|
값으로 1회 호출됨"*과 겹쳐 **바인드마다 `Rerun`이 `Ref` dep 수만큼 돌았다**.
|
|
같은 문제가 State dep에도 있다 — `source-state-plan.md`가 확정한
|
|
*"`fn`은 등록 시점에 즉시 1회 실행된다"* 때문이다.
|
|
|
|
### 확정 구조 — 강한 주인은 항상 `Effect`, 발화 게이트는 `canExecute` 하나
|
|
|
|
```
|
|
Effect ──강──▶ _deps = { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect
|
|
Ref.Callbacks ──약──▶ fn (`ref:WeakCallback(fn)`)
|
|
Observer 전역 레지스트리 ──약──▶ Observer (`observer:WeakSubscribe()`)
|
|
발화 게이트: 전부 `canExecute(handle)` 하나로
|
|
```
|
|
|
|
**사용자 확정(2026-08-25)**: *"그냥 간단하게 저 강한 map 을 Effect 가 가지고,
|
|
WeakSub/WeakUnsub 를 WeakCallback 처럼 넣어줍시다. 의미론은 같습니다.
|
|
callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 않으면 gc 될
|
|
수 있다. **그러면 bindLifetime 은 effect 하나 구현을 한 이후 canExecute 로
|
|
모두 처리한다. 간단해집니다.**"*
|
|
|
|
- **dep마다 바인드/언바인드에서 등록·해제하던 춤이 통째로 사라진다.**
|
|
`bindLifetime`/`unbindLifetime`은 **`Effect` 핸들 하나**에만 적용되고,
|
|
내부 Observer와 `Ref` 콜백의 발화 여부는 `canExecute(handle)`이 전담한다.
|
|
- 그래서 아래 `H-7` 절이 확정한 *"`unbindLifetime`과 `:Unsubscribe()`에서
|
|
`:Uncallback`한다"*는 **더 이상 필요 없다** — `ref-plan.md`의
|
|
*"해제는 누수를, 게이팅은 발화를 막는다"* 중 **앞쪽 절반을 `Weak*`가
|
|
대신한다**.
|
|
- **왜 `WeakRef`가 아니라 `WeakCallback`인가**(사용자 지적): *"Ref 안에
|
|
항상 콜백이 쌓인다는것도 문제가 됨."* 클로저가 핸들을 약하게 잡는
|
|
`WeakRef`는 "`Ref`가 `Effect`를 붙든다"만 풀고 "`Ref.Callbacks`에 죽은
|
|
클로저가 쌓인다"는 못 푼다. `WeakCallback`은 둘 다 푼다 —
|
|
`Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고 `Ref` 쪽
|
|
항목도 같이 사라진다. **`WeakRef`는 만들지 않는다.**
|
|
- 이건 `blocker-plan.md`의 onunblock 핸들 보관과 **같은 패턴**이다 —
|
|
강한 주인은 소비자 쪽, 등록처는 weak.
|
|
|
|
### 의사코드 — 생성자 / `bindLifetime`이 부르는 두 훅 / `Rerun`
|
|
|
|
```lua
|
|
-- quad-base, Effect.luau
|
|
function Effect(fn, ...)
|
|
local self = setmetatable({ fn = fn, _deps = {}, _epochs = EpochMap() }, EffectHandle)
|
|
self._blocker = Blocker()
|
|
|
|
-- (0) deps 검증 — 생성자에서 한 번만 도는 검사라 hot path가 아니다.
|
|
-- `select("#", ...)`로 순회해야 `nil` 구멍이 조용히 배열을 자르지 않는다.
|
|
local seen = {}
|
|
for i = 1, select("#", ...) do
|
|
local d = select(i, ...)
|
|
if d == nil then error("Effect: dep #" .. i .. " is nil", 2) end
|
|
if not (isState(d) or isSource(d) or isRef(d)) then
|
|
error("Effect: dep #" .. i .. " is not a State/Source/Ref", 2)
|
|
end
|
|
if not seen[d] then seen[d] = true end -- 중복 dep은 조용히 무시(error 아님)
|
|
end
|
|
|
|
-- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출은 Blocker로 억제한다.
|
|
-- ⭐ 클로저는 **하나**로 통일한다 — 아래 "공통 상류를 공유해도 한
|
|
-- 파동에 fn은 한 번만" 절의 그 클로저다. `from`을 받아
|
|
-- `_epochs:Update(from)`가 참일 때만 `Rerun`해야 다이아몬드 dedup이
|
|
-- 산다(안 하면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()`
|
|
-- 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그).
|
|
-- 시그니처가 `(self, from)`인 것은 `state:Observer(fn)`의 확정
|
|
-- 계약이다(`base/source-state-plan.md`).
|
|
self._blocker:On()
|
|
local function onDepFire(_, from)
|
|
if not canExecute(self) then return end -- 발화 게이트
|
|
if self._blocker:IsOn() then return end -- 등록 구간 억제(Update보다 먼저)
|
|
if self._epochs:Update(from) then
|
|
self:Rerun()
|
|
end
|
|
end
|
|
for d in seen do
|
|
if isRef(d) then
|
|
self._deps[d] = onDepFire -- ⭐ 강한 주인 = Effect
|
|
d:WeakCallback(onDepFire) -- Ref 쪽은 약함
|
|
else
|
|
local o = d:Observer(onDepFire)
|
|
self._deps[d] = o -- ⭐ 강한 주인 = Effect
|
|
o:WeakSubscribe() -- 전역 레지스트리는 약함
|
|
end
|
|
-- ⭐ dep이 `Epoch`인지로 갈린다 — `state-epoch-plan.md` §4의 시딩 규칙
|
|
-- 그대로다. **`Source`/`Ref`는 `Epoch`지만 `State`는 아니다**(§2·§8) —
|
|
-- 무조건 `Sync`하면 State dep이 `.Revision` 없는 키로 들어가
|
|
-- `Refresh()`가 영영 변화를 못 보고 포탈 캐치업이 죽는다.
|
|
if isEpoch(d) then
|
|
self._epochs:Sync(d)
|
|
else
|
|
self._epochs:TrackFrom(d.valueEpochMap)
|
|
end
|
|
end
|
|
self._blocker:OffWithoutEmit()
|
|
|
|
-- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇).
|
|
self:Rerun()
|
|
return self
|
|
end
|
|
```
|
|
|
|
- **`_installing` 플래그는 폐기됐다** — 그건 생성자 구간만 덮어 바인드
|
|
구간을 놓쳤다. 억제는 사적 `Blocker` 하나가 전담한다(**사용자 지적**:
|
|
*"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 사용중임. 모든
|
|
옵저버와 callback 등록에 있어서 이를 수행해야할 것임."*).
|
|
- **⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다.** **사용자 판단**:
|
|
*"Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행
|
|
결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안
|
|
되거든요. 초기 값이 못 쓰게 되는거죠."* 그 따름정리로 **바운딩 없이
|
|
버려지는 `Effect`는 UB**다 — cleanup이 안 불린다. `Observer`와 달리
|
|
`Effect`는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다.
|
|
|
|
```lua
|
|
function EffectHandle:_bindDestroying(inst)
|
|
self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등
|
|
|
|
-- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점.
|
|
self._destroyConn = onDestroying(inst, function()
|
|
self:_unbindDestroying()
|
|
self:_consumeCleanup()
|
|
end)
|
|
|
|
-- (2) 캐치업 — **조건부 최대 1회**. dep 등록은 이미 생성자에서 끝났다.
|
|
-- 설치돼 있지 않으면(파괴로 소진됐으면) 재설치, dep이 변했으면 재실행.
|
|
-- ⚠️ `Refresh()`를 **먼저 부른다** — 그건 비교만 하는 게 아니라 자기
|
|
-- 키를 라이브로 다시 읽어 **갱신**한다. `or` 단축평가에 걸면
|
|
-- `not self._installed`가 참일 때(재설치 경로) 건너뛰어, 재설치 뒤에도
|
|
-- `_epochs`가 파괴 전 리비전을 들고 있어 **다음 emit이 헛되이 한 번
|
|
-- 더 돈다**(`Rerun`은 `_epochs`를 안 건드린다).
|
|
local depsChanged = self._epochs:Refresh()
|
|
if not self._installed or depsChanged then
|
|
self:Rerun()
|
|
end
|
|
end
|
|
|
|
function EffectHandle:_unbindDestroying()
|
|
if self._destroyConn then
|
|
self._destroyConn:Disconnect()
|
|
self._destroyConn = nil
|
|
end
|
|
-- **`Ref` 콜백도 Observer도 안 뗀다** — `Weak*`로 걸려 있고 발화는
|
|
-- `canExecute`가 막는다. **`_cleanup`도 안 부른다**(아래 2번 계약).
|
|
end
|
|
|
|
-- cleanup 소진: 읽고 → 지우고 → 실행. 이 순서라야 이중 호출이 없고,
|
|
-- `_cleanup`의 유무가 곧 "설치돼 있는가"가 된다.
|
|
function EffectHandle:_consumeCleanup()
|
|
local c = self._cleanup
|
|
self._cleanup = nil
|
|
self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다
|
|
if c then c() end
|
|
end
|
|
```
|
|
|
|
**⚠️ [2026-08-25 `/code-review high` 정정] "설치돼 있는가"를 `_cleanup`의
|
|
유무로 판정하면 안 된다 — 별도 `_installed` 플래그가 필요하다.** 여기 한때
|
|
`if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은
|
|
선택**이라(`Effect(function() print("x") end, s)`처럼 아무것도 안 돌려주는
|
|
게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면
|
|
바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가
|
|
없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.**
|
|
`_installed`는 `Rerun`이 끝날 때 참, `_consumeCleanup`에서 거짓이 된다.
|
|
|
|
**⭐ [2026-08-25 신설, 7라운드 `H-60`] `EffectHandle:Rerun()` 정의.**
|
|
지금까지 호출부만 다섯 곳이고 정의가 없었다.
|
|
|
|
```lua
|
|
function EffectHandle:Rerun() -- 공개 메소드, 무인자
|
|
if self._running then
|
|
self._pending = true -- 실행 중 재진입 → 지연
|
|
return
|
|
end
|
|
self._running = true
|
|
repeat
|
|
self._pending = false
|
|
self:_consumeCleanup()
|
|
self._cleanup = self.fn(self)
|
|
self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨"
|
|
until not self._pending -- 재요청이 또 오면 또 돈다
|
|
self._running = false
|
|
end
|
|
```
|
|
|
|
- **재진입은 지연 재실행**이다. **사용자 판단**: *"Effect 의 실행 안에서
|
|
뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행
|
|
하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는
|
|
상황은 react 등지에서 흔함."*
|
|
- **`canExecute` 확인은 호출부가 한다** — `Ref` 콜백·전파 루프가 이미
|
|
그렇게 한다. 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다.
|
|
- **error 시 UB** — 전파되고 복구하지 않는다(`_running`이 참으로 남는 것
|
|
포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의
|
|
일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다.
|
|
|
|
**⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리
|
|
패턴.** 파괴로 cleanup이 소진된 `Effect`를 다시 바인드하면 위 (2)의
|
|
`not self._installed`가 참이라 **재설치**된다. 죽음을 표시하는 별도 부기는
|
|
만들지 않는다 — **사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에
|
|
이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도
|
|
알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시
|
|
실행해줘야한다는 것 이외엔 아무 문제가 없어요."*
|
|
|
|
같은 `fn`을 **여러 인스턴스로** 쓰고 싶으면 `Effect`를 넘기지 말고
|
|
**팩토리를 넘긴다**:
|
|
|
|
```lua
|
|
local function TimerEffectFactory(data: { timerSource: Source<number> }): Effect
|
|
return Effect(function(self)
|
|
...
|
|
return function() ... end
|
|
end, data.timerSource) -- 주입받은 것을 그대로 deps로도 쓸 수 있다
|
|
end
|
|
```
|
|
|
|
- **사용자 결론**: *"차라리 Effect 를 만들어내는 팩토리를 넘기는 패턴을
|
|
권장해야할듯 해요 … Clone 도, Userdata 도, 템플릿도 필요하지 않다."*
|
|
초기 1회 실행 문제가 자연히 해결되고, 템플릿이 실행되어 찌꺼기로 남는 걸
|
|
막으려 따로 뭘 할 필요도 없다. 자식의 계약은 `({...}) -> Effect` 하나이고
|
|
부모가 더 큰 타입을 넘겨도 **부분 성립**으로 해결된다. 무엇보다 **주입받은
|
|
`Source`/`Ref`를 그대로 deps로 넣을 수 있다** — userdata로는 안 되던 것이다
|
|
(*"이건 ud 가 deps 에 대해서는 아무 처리가 못 했던것과 비교해 더 간단하면서도,
|
|
기능적임"*). modifier에서 이미 권해온 패턴이기도 하다.
|
|
- 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새 인스턴스).
|
|
- **검토 후 안 만들기로 한 것**: `Effect:Clone()`, `Effect<UD>:Userdata()` /
|
|
`SetUserdata`·`GetUserdata`, `Effect.Template`, `WeakRef`.
|
|
|
|
- **`onDestroying(inst, fn)`은 백엔드 주입 op이다** — base는 `Instance`를
|
|
모른다(`base/slot-plan.md`의 `native*` 절과 같은 이유). quad-roblox 구현은
|
|
`inst.Destroying:Connect(fn)` 한 줄. 주입 op 전체 목록의 단일 소스는
|
|
`base/architecture.md`의 `EngineOps.luau` 줄이다.
|
|
- **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건
|
|
`fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref`도 `Epoch`라 균일),
|
|
`_blocker`(등록 구간 억제), `_cleanup`, **`_installed`**(설치 여부 —
|
|
cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다),
|
|
`_running`/`_pending`(재진입).
|
|
**옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나와
|
|
`_blocker`로 대체됐다.**
|
|
|
|
### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`)
|
|
|
|
> **⭐⭐ [2026-08-25 갱신, 7라운드 `H-58`/`H-59`] 해제 경로 대신 `Weak*`
|
|
> 등록으로 바뀌었다.** 아래가 진단한 누수(*"leaf가 죽어도 `ref.Callbacks`에
|
|
> 클로저가 영원히 남는다"*)는 그대로 유효하지만, **해법이 바뀌었다** —
|
|
> `unbindLifetime`/`:Unsubscribe()`에서 `:Uncallback`하는 대신
|
|
> `ref:WeakCallback(cb)`로 걸고 **강한 주인을 `Effect._deps`에 둔다**.
|
|
> 그러면 `Effect`가 죽을 때 콜백도 같이 죽어 항목이 자연히 사라지고,
|
|
> 바인드/언바인드마다 떼었다 붙이는 춤이 없어진다(그 춤이 `H-58`의
|
|
> 중복 `Rerun`을 만들던 원인이다). 위 "확정 구조" 절이 소스.
|
|
> **아래 `canExecute` 게이팅은 그대로 유효하다** — *"해제는 누수를,
|
|
> 게이팅은 발화를 막는다"* 중 **뒤쪽 절반**이 여전히 이 절의 결론이다.
|
|
|
|
`Effect(fn, someRef)`의 leaf가 죽어도 **`ref.Callbacks`에 클로저가 영원히
|
|
남았다** — `Ref`엔 콜백 해제 API가 없었고(`:Uncallback` 류 없음)
|
|
`canExecute` 게이팅도 안 걸린다(그건 Observer 쪽 배관이다). 그 클로저가
|
|
`EffectHandle`을 강참조하므로 누수이고, 이후 `ref:Set`마다 **이미 죽은 leaf의
|
|
직전 cleanup + `fn`을 계속 실행**한다. 같은 절이 *"leaf dedup/cascade가 전부를
|
|
덮어야 한다"*고 요구하는데 `Ref` 쪽엔 그걸 만족시킬 수단 자체가 없었다.
|
|
|
|
**확정: `Ref`에 콜백 해제 경로를 추가한다**(`base/ref-plan.md`가 소스).
|
|
`EffectHandle`은 자기가 건 `Ref` 콜백 핸들을 들고 있다가, `unbindLifetime`과
|
|
`:Unsubscribe()`에서 같이 해제한다 — `_observers`(State/Source dep)와 대칭이다.
|
|
|
|
**⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도
|
|
발화 시점에 `canExecute`를 확인한다.** State/Source dep은 State의 전파 루프가
|
|
구독자마다 `canExecute(observer)`를 보고 죽은 것을 건너뛰는데(`base/source-state-plan.md`),
|
|
`Ref` 경로엔 그 게이트가 아예 없었다. 해제가 늦거나 누락되는 창이 실재한다 —
|
|
예컨대 `unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이
|
|
안 도는데도 `canExecute`가 거짓이다. **확정된 형태**: `Effect`가 거는 `Ref`
|
|
콜백은 본문 맨 앞에서 **자기 핸들에 대해** `canExecute(handle)`를 확인하고,
|
|
거짓이면 그대로 리턴한다. 그러면 두 dep 경로가 같은 게이트를 공유하게 되어
|
|
`Effect`의 발화 조건이 dep 종류와 무관해진다.
|
|
|
|
### 동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK`
|
|
|
|
(2026-08-14 열한 번째 세션, `PreRef`/`Observer`와 같은 패턴, `base/
|
|
source-state-plan.md`의 "동적 경로 가드" 절 참고.)
|
|
**⚠️ [2026-08-24] 이 가드를 실제로 `Dispatch.addHandler`로 등록하는 것은
|
|
M3(디스패치)다.** `HANDLER_PRIORITY_FALLBACK` 상수도 `Dispatch.addHandler`도
|
|
M3에서 처음 생기므로, M2(반응형 코어)에서 본체를 짤 때는 **핸들러 정의만
|
|
준비해두고 등록 호출은 미룬다** — `ROADMAP.md` M3의 "Observer/Effect 동적
|
|
경로 가드 등록" 체크박스가 그 자리다(2026-08-24 마일스톤 순서 교체의 산물,
|
|
M2가 M3에 개념상 지던 유일한 의존이라 이쪽으로 미뤄졌다).
|
|
`EffectHandle`도
|
|
children 배열 리터럴 전용이라, 해시 파트 named 자리 등으로 동적으로
|
|
흘러들어오면 명확히 에러내야 함 — `{ priority = HANDLER_PRIORITY_FALLBACK,
|
|
isHandlable = function(inst,k,v) return isEffect(v) end, process =
|
|
function(inst,k,v) error(`Effect binding should be array index item, but
|
|
got {typeof(k)}`) end }`(**[2026-08-18]** 에러 메시지에 실제 `k` 타입을
|
|
실을 것 — `base/source-state-plan.md`의 "동적 경로 가드" 절).
|
|
`FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에
|
|
named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로
|
|
값싸게 override 가능한 자리로 열어둠.
|
|
|
|
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째
|
|
세션, 재확인 후 명시화)**:
|
|
|
|
> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.**
|
|
> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 —
|
|
> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고**
|
|
> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만**
|
|
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`이
|
|
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
|
|
> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록").
|
|
> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`이
|
|
> 되살아난다.**
|
|
|
|
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나"
|
|
전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.**
|
|
아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로
|
|
구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려**
|
|
그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패
|
|
모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로
|
|
고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이
|
|
배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스.
|
|
|
|
- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] =
|
|
observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라
|
|
(그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime`
|
|
cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것.
|
|
- **`bindLifetime(inst, handle)`은 `state`가 있는 경우 내부 Observer도
|
|
같은 `inst`로 `handle._observers` **전부**에 대해
|
|
`bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해
|
|
`bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`가
|
|
`handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유:
|
|
`canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이
|
|
`bindLifetime(inst, observer)`될 때 그 Observer 쪽 릴레이션에
|
|
복사되는 것**이라, `EffectHandle`만 바인드하고 내부 Observer는 안 하면
|
|
그 Observer에겐 판정 근거가 아예 없어서 `canExecute`가 항상 거짓이 됨
|
|
(=재실행이 통째로 죽음). 같은 이유로 `unbindLifetime(handle)`도 내부
|
|
Observer까지 같이 풀어야 대칭이 맞음.
|
|
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든
|
|
"`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는
|
|
틀렸음 — `.Subscribed`는 전역 `:Subscribe()` 전용이고 leaf 경로와
|
|
무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는
|
|
결론은 그대로이고 오히려 근거가 더 직접적이 됨.
|
|
- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은
|
|
전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers`
|
|
전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든
|
|
"`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면
|
|
안 됨).
|
|
|
|
**Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React
|
|
`useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나,
|
|
클로저 업밸류로 이미 충분해 채택 안 함. 이 기각은 위 Effect 설계와
|
|
상충하지 않음(그때 기각한 건 "Observer 자체에 이 복잡도를 넣지 말자"였지
|
|
패턴 자체의 무용함이 아니었고, `Effect`가 opt-in 상위 계층으로 정확히
|
|
이 패턴을 제공함) — 상세 경위는 `archive/observer-cleanup-contract-rejected.md`
|
|
참고.
|
|
|
|
## `EffectHandle:Subscribe()`/`:Unsubscribe()` — leaf 없이 쓰는 독립 Effect (2026-08-07 일곱 번째 세션)
|
|
|
|
**동기**: 지금까지 Effect의 유일한 생애주기 경로는 children 배열의 leaf
|
|
부착뿐이었음 — leaf 없이 `Effect(fn)`/`Effect(fn, state)`를 호출하면
|
|
설치(1회 실행)는 되지만 반환된 `EffectHandle`엔 아무 인터페이스도 없어서
|
|
cleanup을 트리거할 방법이 없는 막다른 길이었음. `state:Observer(fn)`가
|
|
이미 `:Subscribe()`/`:Unsubscribe()`(위 bind-system-plan.md 절)로 "children
|
|
배열 밖, 모듈/스크립트 레벨에서 독립적으로 켜고 끄는" 경로를 갖고 있는데,
|
|
Effect도 모듈/스크립트 사이드 이펙트(백그라운드 시스템, non-UI 코드가
|
|
quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 쓰일 수 있어서
|
|
같은 결로 필요 — `Effect`도 leaf 없이 독립적으로 켜고 끌 수 있어야 함.
|
|
|
|
**확정**: `EffectHandle`에도 `:Subscribe()`/`:Unsubscribe()` 추가, 둘 다
|
|
`self` 반환(Observer와 동일한 fluent 대칭).
|
|
|
|
- **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에
|
|
**핸들 자신**을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후
|
|
로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구).
|
|
- **⭐ [2026-08-25 확정, 7라운드 `H-59`] "핸들 자신이냐 내부 Observer냐"는
|
|
더 이상 구현 세부가 아니다 — 핸들 자신이다.** 옛 서술은
|
|
*"자신(또는 `state` 있는 경우 내부 Observer)"*, *"`handle._observers`만으로
|
|
충분한지는 구현 세부"*였는데, `H-7`/`H-11`이 **핸들 자신의 생존 판정**에
|
|
의존하는 배선을 추가하면서 그 선택이 계약이 됐다. 내부 Observer만
|
|
등록하면 (a) `handle.Subscribed`가 안 세워져 `canExecute(handle)`이
|
|
영원히 거짓이고, (b) deps 없는 `Effect(fn):Subscribe()`는 등록할 게
|
|
아예 없어 핸들이 GC되고 cleanup이 유실된다.
|
|
- **`:Subscribe()`가 하는 일은 그것 하나뿐이다** — 내부 Observer와 `Ref`
|
|
콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절).
|
|
`Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그
|
|
경로들이 살아난다.
|
|
- **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
|
|
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`에
|
|
묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지
|
|
않는 게 정상 경로.** `:Subscribe()`를 쓰기로 했다면(top-level이든
|
|
의도적으로 다른 경우든) **반드시 `:Unsubscribe()`로 짝을 맞춰야
|
|
함** — 강참조 레지스트리는 quad 전역의 "정리는 기본적으로 GC에
|
|
위임" 원칙의 **의도적 예외**라, 로컬 변수 참조를 다 놓아도(스코프를
|
|
벗어나도) **GC되지 않고 계속 실행됨**. 이건 quad의 다른 프리미티브
|
|
대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자
|
|
문서에 명시적으로 경고할 것(`:Subscribe()`를 부르는 순간부터 그
|
|
핸들의 생애주기는 전적으로 수동 관리 대상이 됨).
|
|
- **⚠️ [축소, 2026-08-18 구현 전 QA] `:Unsubscribe()`는 `:Subscribe()`의
|
|
짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는
|
|
**`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`를
|
|
부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나,
|
|
최소한 그 경로에서 cleanup을 앞당기면 안 된다.
|
|
**[강화, 2026-08-20 구현 전 QA 4라운드 `E-11`] "안 되거나/최소한"이 아니라
|
|
Observer와 정확히 같은 규칙으로 통일한다 — leaf 바인딩된 핸들에는
|
|
`:Unsubscribe()`가 아예 안 먹는다.** 사용자 지적: *"옵저버에선 leaf
|
|
바인딩에 Unsubscribe 못 하는것 처럼, Effect 또한 리프 바인딩에 있어서는
|
|
Unsubscribe 안 먹어야 하는거 아님?"* — 맞다. `Observer`의
|
|
`:Unsubscribe()`가 전역 경로 전용이고 leaf 해제는 `unbindLifetime`이
|
|
담당한다는 게 이미 확정된 규칙인데(`base/source-state-plan.md`의 "이중
|
|
바인딩 금지" 절), `Effect`만 애매하게 열어두면 두 프리미티브의 규칙이
|
|
갈린다. **`State<Effect>` 재-dispatch와의 상호작용도 이 통일로 같이
|
|
닫힌다** — leaf 바인딩된 핸들엔 `:Unsubscribe()`가 아예 안 먹으므로,
|
|
아래 dedup 시나리오(값이 안 바뀌어 retract가 no-op인데 cleanup만
|
|
앞당겨져 Effect가 조용히 죽는 것)가 발생할 경로 자체가 없어진다. 사용자 판정: *"subscribe
|
|
한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선
|
|
그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."*
|
|
- **왜 위험한가**: leaf 바인딩 + `State<Effect>`/`State<Observer>`
|
|
조합에서, 값이 실제로 안 바뀌면 **dedup 최적화 때문에 retract가 아무
|
|
일도 안 한다**(`base/source-state-plan.md`의 "Observer/Effect Leaf
|
|
dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리
|
|
실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안
|
|
일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님.
|
|
- **✅ [해소, 2026-08-21 — 구현 전 QA 4라운드 `E-10` 결론을 5라운드 `EF-3`에서
|
|
실제로 반영] dedup 경로의 process/retract 대칭은 성립한다.** 4라운드에
|
|
결론이 났는데 **이 문서에 반영이 누락돼 "미해결"로 남아 있던 것**을
|
|
5라운드가 잡아냈다(그 자체가 followup의 "반영 완료" 표를 신뢰 소스로
|
|
쓰면 안 된다는 사례 — 소스는 항상 `base/` 본문).
|
|
- **성립하는 이유**: 핸들러가 **이전 값(`old`)을 `Relate`로 직접 들고
|
|
있고**, `process`의 `if old ~= v then ... end`와 클로저의
|
|
`if nextValue ~= v then ... end` **두 분기 안에서만** bind/unbind가
|
|
일어난다. 값이 같으면 retract도 아무것도 안 하고(= `old`를 지우지
|
|
않는다) `process`도 조회해서 같으면 그대로 넘어간다 — 양쪽이 같은
|
|
비교식을 쓰므로 한쪽만 도는 상태가 안 생긴다. **사용자 서술**(2026-08-20):
|
|
*"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup
|
|
이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면
|
|
dedup 되어야하는듯."*
|
|
- **⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] `EF-5`의 "내부 Observer
|
|
cascade도 그 분기 안에" 요구는 사라졌다.** 원문은 *"`EffectHandle`은
|
|
자기 자신뿐 아니라 `handle._observers`까지 같이 bind/unbind해야 하는데,
|
|
그 cascade가 dedup 분기 밖에 있으면 handle과 내부 Observer의 바인딩
|
|
상태가 갈린다"*였는데, **`bindLifetime`/`unbindLifetime`이 이제 핸들
|
|
하나에만 적용되고 cascade 자체가 없다**(위 "확정 구조" 절,
|
|
`_observers` 필드도 `_deps`로 통합돼 폐기). 갈릴 상태가 없으므로
|
|
이 요구는 성립하지 않는다 — **그대로 구현하면 `nil`을 순회한다.**
|
|
- **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린
|
|
항목이 아니다.
|
|
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥
|
|
위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의
|
|
`:Unsubscribe()`는 "미래 재실행만
|
|
끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의
|
|
계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"
|
|
이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도
|
|
동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨:
|
|
1. **⚠️ [2026-08-25 정정, 7라운드 `H-58`/`H-59`]** 여기 원래 *"`state`가
|
|
있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 끊고"*라
|
|
적혀 있었는데, 내부 Observer는 생성자에서 `:WeakSubscribe()`로 걸리고
|
|
**해제하지 않는다** — 발화는 `canExecute(handle)`이 막는다(위
|
|
"확정 구조" 절). `:Unsubscribe()`가 `handle.Subscribed = false`로
|
|
만들면 그 게이트가 곧바로 거짓이 되므로 **향후 재실행은 그것만으로
|
|
끊긴다.** 단수 `state` 전제도 이미 `...deps`로 대체됐다.
|
|
2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때
|
|
하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것.
|
|
3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복
|
|
호출되면 안 됨** — 새 메커니즘 불필요, Observer가 이미 확정해둔
|
|
`canExecute(value)` liveness 체크가 자동(리프=gcconn 참조)/수동
|
|
(전역=`Subscribed` 필드) 두 경로를 하나의 게이트로 OR 묶어주므로
|
|
여기 그대로 얹힘.
|
|
- **`state` 없는 mount-only Effect엔 특별한 분기 불필요** — install은 이미
|
|
`Effect(fn)` 호출 시점에 끝나 있으므로, `:Unsubscribe()`는 그냥 "지금
|
|
leaf-사망 cleanup을 수동으로 트리거"하는 것과 완전히 동치.
|
|
- **leaf 부착과 `:Subscribe()`를 동시에 쓰는 건 UB — 정정(2026-08-07
|
|
일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니
|
|
동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩
|
|
경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세
|
|
규칙과 `canBound(value)` 기반 즉시-에러 메커니즘(구 가칭 `Bound`
|
|
플래그 → 2026-08-09 세션에 `canBound`로 명명 → 2026-08-14 다섯 번째
|
|
세션에 `canBound` 폐기, `canExecute`로 통합 → **같은 날 열한 번째
|
|
세션에 `canBound`가 별도 진입점으로 재도입**, 판정 로직은
|
|
`canExecute`와 공유)은 `base/source-state-plan.md`의 "이중 바인딩
|
|
금지" 절 참고. **[정정,
|
|
2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`가
|
|
아니라 `unbindLifetime(value)`** — leaf 부착 자체가 내부적으로
|
|
`bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime`
|
|
전용(`:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함) — 금지되는 건
|
|
여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함,
|
|
inst-scoped 경로)을 **같이** 쓰는 것뿐.
|
|
|
|
## ⭐ `Effect(fn, ...deps)` — 여러 의존성을 직접 받는다, `Ref`도 포함 (2026-08-21 구현 전 QA 5라운드 `C-6` 확정)
|
|
|
|
**갭이 실재했다**: 지금 `Effect`는 `state` 하나만 받고, 여럿을 엮으려면
|
|
`:With`로 합쳐 하나의 State로 만들어야 한다. 그런데 **`Ref`는 State가 아니라**
|
|
(`:Callback`만 있고 emit이 없다) `:With`로 합칠 수가 없어서, **오늘은 `Ref`가
|
|
Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 가 지금은 Ref에
|
|
대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로
|
|
합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐."*
|
|
|
|
**확정된 계약**(전부 사용자 확인, 2026-08-21):
|
|
|
|
- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** —
|
|
State/Source면 `Observer`, `Ref`면 `:Callback`. `:With`로 합치지 않는다.
|
|
**[2026-08-25 정정]** 각각 `:WeakSubscribe()` / `:WeakCallback()`으로
|
|
걸고, 강한 주인은 `Effect._deps`다(위 "확정 구조" 절).
|
|
- **⭐ [2026-08-25 확정, 7라운드 `H-70`] deps 검증** — 지금까지
|
|
비어 있던 세 자리를 정한다.
|
|
- **`nil` dep은 error.** `{...}` + `ipairs`로 순회하면 `nil` 구멍 뒤가
|
|
조용히 잘리므로 **`select("#", ...)`로 돌아야** 한다.
|
|
- **State/Source/`Ref`가 아닌 값은 error.** `H-40`이 `:List`의 요소
|
|
검증을 블랙리스트에서 화이트리스트로 뒤집은 것과 같은 성격이다 —
|
|
이물 dep은 전파할 것이 없으므로 조용히 무시하면 "왜 안 발화하지"만
|
|
남는다.
|
|
- **중복 dep은 조용히 무시**(error 아님). **사용자 근거**: *":With 이나
|
|
시소한 연산으로 다른 State 가 된다던가 하면 deps 가 겹쳐도, 근원
|
|
source 가 겹쳐도 에러를 안 냄. Ref 도 유사한 부분."* `Ref`가 `Epoch`로
|
|
승격돼(`base/ref-plan.md`) `EpochMap`이 키로 dedup하므로 **공짜로**
|
|
처리된다 — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로 먼저 건 클로저가
|
|
`Ref.Callbacks`에 남던 버그도 같이 사라진다.
|
|
- 검증은 전부 **생성자에서 한 번만** 도므로 hot path가 아니다.
|
|
error `level`은 **2**(사용자 입력 검증) — `base/architecture.md`의
|
|
error 계약 절.
|
|
- **⭐ [확정, 2026-08-24 6라운드 손 트레이싱 `H-14`] `fn`의 시그니처는
|
|
`fn(self: EffectHandle) -> ...(() -> ())` 이고(**[2026-08-25 `H-95`]** 가변
|
|
반환 팩으로 정정 — 옛 `-> (() -> ())?`는 정상 용례를 막았다), `...deps`는 의존성 선언일 뿐
|
|
`fn`에 넘어가지 않는다.**
|
|
**사용자 확정**: *"`Effect( fn(self: Effect)->()->(), ...deps )` 가 맞는듯.
|
|
Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 맞아보이고,
|
|
Compute 랑은 완전 다름. 난 그냥 `...deps` 넣는게 compute 처럼 그냥 넣을 수
|
|
있게 하자는거였을 뿐임."*
|
|
- **여기 원래 적혀 있던 *"인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로 —
|
|
trailing deps를 lazy 위치 인자로 콜백에 넘긴다"*는 삭제한다.** 그 선례의
|
|
확정 시그니처는 `fn(self, previous?, ...deps)`인데 `Effect`엔 **셋 다 안
|
|
맞는다**: `self` 자리에 올 리시버 State가 없고(자유 함수), `previous`가
|
|
담길 캐시 슬롯이 없으며(파생값을 안 만드는 leaf), 오히려 **반환값이
|
|
cleanup이라 의미가 정반대**다. 위쪽에 남아 있던 옛 단수 시절 표기
|
|
`fn(state)`도 같이 폐기된다.
|
|
- **그 따름정리로 dep 읽는 법의 비대칭 문제가 사라진다** — State/Source dep은
|
|
`:Get()`이고 `Ref` dep은 `.Value`(`:Get()`이 없다)라, 넘겨줬다면 사용자가
|
|
인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문
|
|
자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다.
|
|
- `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은
|
|
핸들 표면에 바로 닿는다.
|
|
- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`가
|
|
섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게
|
|
될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다.
|
|
- **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이
|
|
가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.
|
|
- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록
|
|
즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에
|
|
한 번만 실행한다.
|
|
**⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] 그 억제는 `Effect` 내부 플래그
|
|
(`_installing`)가 아니라 사적 `Blocker` 하나가 한다.** 여기 한때
|
|
*"[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 — 게이트도 `Blocker`도
|
|
안 쓴다"*고 적혀 있었는데, 그 플래그는 **생성자 구간만 덮어 바인드 구간을
|
|
놓쳤다**(`Ref` 콜백을 바인드마다 재등록하던 옛 모델에서 `Rerun`이 dep 수만큼
|
|
돌았다). 지금은 dep 등록이 **생성자 한 곳**으로 모였고 억제는
|
|
`self._blocker:On()` … `:OffWithoutEmit()` 구간이 맡는다 —
|
|
`materializeSlotTree`가 쓰는 관용구와 같은 모양이고, 위 "확정 구조" 절과
|
|
생성자 의사코드가 소스다. **`_installing`은 폐기된 필드다.**
|
|
(2026-08-21에 `Gate` 재사용을 접었던 근거 — *"설치 구간엔 어떤 `Set`도 안
|
|
일어나 게이트에 쌓이는 소스가 없다"*, `base/gate-plan.md`의 8번 — 는 그대로
|
|
유효하다. 게이트가 아니라 `Blocker`를 쓰는 이유이기도 하다.)
|
|
- **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만
|
|
돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`,
|
|
`A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를,
|
|
`c`가 자기 observer를 **각각 정당하게** 깨워 `fn`이 두 번 돌았다. State 층
|
|
dedup으론 안 접힌다 — `b`와 `c`는 서로 다른 노드라 접어줄 **공통 하류가
|
|
없고**, 둘 다 §4의 1번 규칙에 정당하게 걸린다(`base/state-epoch-plan.md`).
|
|
위의 "설치 구간 억제"도 이건 안 덮는다(그건 등록 시점만).
|
|
- **확정된 해법**: `EffectHandle`이 `EpochMap`을 **하나** 들고, 각 내부
|
|
Observer의 클로저가 받은 `from`으로 그걸 `Update`한다. **`true`일 때만
|
|
`fn`을 부른다.** `Effect`가 곧 그 dep들의 **공통 하류**가 되므로, 한
|
|
파동에 몇 개가 깨우든 첫 번째만 통과한다.
|
|
```lua
|
|
-- 각 dep의 내부 Observer가 공통으로 거는 클로저
|
|
function(self, from)
|
|
if not canExecute(handle) then return end -- 발화 게이트
|
|
if handle._blocker:IsOn() then return end -- 등록 구간 억제 (위 정정)
|
|
if handle._epochs:Update(from) then
|
|
handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행
|
|
end
|
|
end
|
|
```
|
|
**⚠️ 억제 확인이 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회
|
|
실행에는 `from`이 없어서(`nil`, `base/source-state-plan.md`의
|
|
"`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면
|
|
설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다
|
|
(2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가
|
|
`_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로다.
|
|
- **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** —
|
|
여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from`이
|
|
없다"*고 적혀 있었는데, **`Ref`가 `Epoch`로 승격**되며(`base/ref-plan.md`)
|
|
공개 `.Revision`을 갖게 됐다. 그래서 `_epochs`가 State/Source/`Ref`를
|
|
**같은 방식으로** 담고(**⚠️ [2026-08-25 정정]** 한때 여기 "State/Source/
|
|
`Ref`를 균일하게"라 적었는데 **`State`는 `Epoch`가 아니다** — 등록이
|
|
`isEpoch`로 갈려 `Source`/`Ref`는 `:Sync`, `State`는 `:TrackFrom`이다,
|
|
`base/state-epoch-plan.md` §4·§8), 같은 `Ref`를 두 번 dep으로 넣어도 키 dedup으로 접힌다
|
|
(`H-70`). `Ref`가 반복 재설정마다 도는 계약 자체는 안 바뀐다 — 리비전이
|
|
매번 갱신되므로 `Update`가 매번 `true`다.
|
|
- **검토했다 접은 대안**: deps를 하나의 파생 노드로 수렴시켜 다이아몬드
|
|
dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 바꿔야 해서
|
|
위 안보다 못하다. `useEffect`처럼 "N번 돌아도 무방"으로 계약을 느슨하게
|
|
두는 선택지도 있었으나, 접는 비용이 맵 하나뿐이라 채택 안 함.
|
|
- 근거 기록은 `reference/epoch-brand-composition.md`(이 갭이 `EpochMap`
|
|
분리의 직접 발단이었다).
|
|
- **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도
|
|
N개다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 모든 옵져버들이
|
|
내부에 들어가 있을것이므로 가능하다."*
|
|
**⭐ [2026-08-25 정정, 7라운드 `H-58`/`H-59`] 다만 그 "전부"를 덮는 주체는
|
|
bind/unbind cascade가 아니다** — `bindLifetime`/`unbindLifetime`은 이제
|
|
**핸들 하나에만** 적용되고, N개 dep의 발화 여부는 `canExecute(handle)`
|
|
하나가 전담한다(위 "확정 구조" 절). 등록도 생성자 한 곳에서 끝난다.
|
|
|
|
**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M2의
|
|
`Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에
|
|
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Gate`에 안 걸린다.
|
|
**[2026-08-25 정정]** 억제 수단이 내부 플래그에서 **사적 `Blocker`**로
|
|
바뀌었으므로 선행은 `Blocker`의 기본 메커니즘(`On`/`Off`/`IsOn`/
|
|
`OffWithoutEmit`)이다 — 그건 `GateNode`/`:Policy`와 무관하게 독립 완결이라
|
|
`Gate`보다 뒤일 필요는 여전히 없다(`ROADMAP.md` M2의 그 각주).
|
|
|
|
## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체)
|
|
|
|
**과거 미해결이었던 두 질문 모두 확정**:
|
|
1. Effect는 자유 함수로 확정(`state:Effect(fn)` 메소드 아님) — 위 "Effect와
|
|
Observer의 관계 확정" 절 참고. `state` 인자가 있어도 실제 leaf 생명주기
|
|
바인딩을 `state`가 소유하지 않아서 메소드로 만들 필연성이 없었음.
|
|
2. `state:Observer(fn)`는 등록 즉시 1회 실행되는 것으로 확정(`base/
|
|
bind-system-plan.md`의 Observer 절 참고) — 이 덕에 Effect가 `state`를
|
|
받을 때 Observer를 그대로 조합해 재사용할 수 있게 됨(별도 "설치 시
|
|
1회 실행" 로직을 Effect가 따로 만들 필요 없음).
|
|
|
|
`.claude/question.md`의 관련 항목도 해소됨으로 갱신 완료(그 항목은
|
|
이후 `archive/question-resolved.md`로 이전).
|