`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
53 KiB
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.md8번)와 같은 결.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):
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한다.
- **한때 근거로 든 "게이트는 값 타입을 안 가린다"는 이 자리에 안 맞는
인용이었다 —
unbindLifetime은 cleanup을 부르지 않는다.Destroying커넥션을 끊고Ref콜백도 같이 해제하되(아래H-7절과 대칭), cleanup은 그대로 남긴다 —destroySlotTree가_detachCleanup을unbindLifetime하며 달아둔 주석("이미 손으로 비웠으니 Effect는 할 일 없음")과E-11(leaf 바인딩엔:Unsubscribe()가 안 먹는다)이 그 전제 위에 서 있다. 이 계약을 명시한다 — 지금까진 어느 쪽도 안 적혀 있었다.- bind/unbind가 대칭이라 포탈이 자연히 성립한다 — 언마운트가 콜백을
떼고 재마운트의
bindLifetime이 다시 건다(_observerscascade와 같은 결).
- bind/unbind가 대칭이라 포탈이 자연히 성립한다 — 언마운트가 콜백을
떼고 재마운트의
- 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
-- 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는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다.
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() 정의.
지금까지 호출부만 다섯 곳이고 정의가 없었다.
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를 넘기지 말고
팩토리를 넘긴다:
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()/bindLifetimecascade가 이 필드를 통해 내부 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-25 확정, 7라운드
- ⚠️ [축소, 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 기준] 남은 건 구현 시 회귀 확인뿐이고, 설계상 열린 항목이 아니다.
- 성립하는 이유: 핸들러가 이전 값(
- 왜 위험한가: leaf 바인딩 +
:Subscribe()한 핸들에서는:Unsubscribe()가 Observer의 것을 그냥 위임하지 않는다 — Effect 계층에서 의미가 확장됨. Observer의:Unsubscribe()는 "미래 재실행만 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라,:Unsubscribe()도 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨:- ⚠️ [2026-08-25 정정, 7라운드
H-58/H-59] 여기 원래 *"state가 있으면 내부 Observer도:Unsubscribe()해서 향후 재실행을 끊고"*라 적혀 있었는데, 내부 Observer는 생성자에서:WeakSubscribe()로 걸리고 해제하지 않는다 — 발화는canExecute(handle)이 막는다(위 "확정 구조" 절).:Unsubscribe()가handle.Subscribed = false로 만들면 그 게이트가 곧바로 거짓이 되므로 향후 재실행은 그것만으로 끊긴다. 단수state전제도 이미...deps로 대체됐다. - 직전(또는 유일한) cleanup을 정확히 1회 호출 — leaf가 죽을 때 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것.
- idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복
호출되면 안 됨 — 새 메커니즘 불필요, Observer가 이미 확정해둔
canExecute(value)liveness 체크가 자동(리프=gcconn 참조)/수동 (전역=Subscribed필드) 두 경로를 하나의 게이트로 OR 묶어주므로 여기 그대로 얹힘.
- ⚠️ [2026-08-25 정정, 7라운드
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 검증 — 지금까지 비어 있던 세 자리를 정한다.nildep은 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-25H-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()이고Refdep은.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들의 공통 하류가 되므로, 한 파동에 몇 개가 깨우든 첫 번째만 통과한다.
⚠️ 억제 확인이-- 각 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 endUpdate보다 먼저여야 한다 — 등록 시점의 즉시 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 여섯 번째 세션, 이전 미해결 절 대체)
과거 미해결이었던 두 질문 모두 확정:
- Effect는 자유 함수로 확정(
state:Effect(fn)메소드 아님) — 위 "Effect와 Observer의 관계 확정" 절 참고.state인자가 있어도 실제 leaf 생명주기 바인딩을state가 소유하지 않아서 메소드로 만들 필연성이 없었음. state:Observer(fn)는 등록 즉시 1회 실행되는 것으로 확정(base/ bind-system-plan.md의 Observer 절 참고) — 이 덕에 Effect가state를 받을 때 Observer를 그대로 조합해 재사용할 수 있게 됨(별도 "설치 시 1회 실행" 로직을 Effect가 따로 만들 필요 없음).
.claude/question.md의 관련 항목도 해소됨으로 갱신 완료(그 항목은
이후 archive/question-resolved.md로 이전).