quad/.claude/base/effect-plan.md
qwreey ecc6b0e538
qa: 10라운드 후속 H-158~H-162 반영 — :Block 폐기(__apply), _rerunRequired 홀드(_installed 흡수), Claim M5, Void export
- H-158 state:Block → state:Apply(blocker), Blocker:__apply 메소드형(호출 규약 명시)
- H-159 사용자 제안 _rerunRequired 홀드: fire=Update→Rerun, rawRerun이 실행 불가 상태의 요청을 홀드,
  _installed 폐기, Observer 대칭(전파 루프 else + bind/subscribe 1회 발화, 생성자 순서 fn→_subs)
- H-160 홀드로 정정 + "error 나면 그 Effect는 죽는다" 계약 / H-161 Claim M5 스코프 / H-162 Void.luau 잎 모듈
- 감사 7→6→1→0, /code-review high 10건 중 8 반영, 둘(H-163 Slot 내부 Observer×홀드, H-164 emitFrom nil)은 문항으로

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 14:43:56 +09:00

75 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 표기 정정]fnfn(state)가 아니라 fn(self)다 — 위 배너), ModifierBoldify(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-2Effect가 그 훅을 쓰는 유일한 소비자라 확정했으며, 위 문단은 비용까지 적어뒀다. 그런데 leaf가 실제로 붙는 유일한 경로는 ObserverEffectLeafHandler.processbindLifetime(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/unbindLifetimeisEffect(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은 이미 값 종류를 들여다보는 자리이므로, 그 옆에 Destroying 배선을 두는 게 새 층을 만드는 것보다 낫다.

    **⚠️⚠️ [2026-08-26 정정, /code-review high] 이 항목이 근거로 들던 *"bindLifetime은 이미 핸들의 내부 Observer로 cascade한다"*는 거짓이다. H-58(2026-08-25)이 그 cascade를 폐기했다 — base/lifecycle-pattern.mdbindLifetime 의사코드가 *"내부 Observer로 cascade하지 않는다"*고 명시하고, 그대로 두면 바인드마다 Rerun이 도는 H-58이 되살아난다(dep 등록은 생성자에서 한 번만, 발화 게이팅은 전부 canExecute(handle)). 8라운드 H-114 반영 때 옛 필드명 handle._observers_deps이름만 고치는 바람에, 폐기된 동작 주장이 오히려 갓 정비된 것처럼 보이게 됐다. 결론(Destroying 배선을 bindLifetime 옆에 둔다)은 그대로 유효하다 — 근거만 바뀐다: 그 함수가 isEffect를 보고 _bindDestroying을 부르는 훅 자리이기 때문이다(H-11).

    • 한때 근거로 든 "게이트는 값 타입을 안 가린다"는 이 자리에 안 맞는 인용이었다base/source-state-plan.md의 그 절이 말하는 건 canBound 판정:Subscribe()/bindLifetime 두 진입점에서 같다는 것이지, bindLifetime부수 배선이 값 종류를 못 본다는 게 아니다. 실제로 그 함수는 isEffect(value)를 보고 _bindDestroying을 부른다 ([2026-08-26 재정정, /code-review high]** 여기 한때 *"Effect면 내부 Observer로 cascade한다"*고 적혀 있었으나 그 cascade는 H-58이 폐기했다 — 위 ⚠️⚠️ 배너가 소스. 이 항목이 말하려는 것(그 함수가 값 종류를 본다)은 isEffect 분기로 그대로 성립한다).
  2. unbindLifetime은 cleanup을 부르지 않는다.

    ⚠️ [2026-08-26 정정, 8라운드 H-114] 아래 두 문장 중 "Ref 콜백도 같이 해제" / "언마운트가 콜백을 떼고 재마운트가 다시 건다"는 폐기됐다. 하루 뒤(2026-08-25) H-58이 정반대로 확정했다 — 같은 파일의 _unbindDestroying 의사코드가 소스이고, 거기선 Ref 콜백도 Observer도 안 뗀다(그래야 바인드마다 Rerun이 도는 걸 막는다). 살아 있는 것은 "cleanup을 안 부른다"는 이 항목의 제목뿐이다. Destroying 커넥션을 끊고 ~~Ref 콜백도 같이 해제**하되(아래 H-7 절과 대칭)~~, cleanup은 그대로 남긴다 — destroySlotTree_detachCleanupunbindLifetime하며 달아둔 주석("이미 손으로 비웠으니 Effect는 할 일 없음")과 E-11(leaf 바인딩엔 :Unsubscribe()가 안 먹는다)이 그 전제 위에 서 있다. 이 계약을 명시한다 — 지금까진 어느 쪽도 안 적혀 있었다.

    • bind/unbind가 대칭이라 포탈이 자연히 성립한다 — 언마운트가 콜백을 떼고 재마운트의 bindLifetime이 다시 건다. [2026-08-26 폐기, H-114] 위 배너대로 H-58이 뒤집었다. 포탈이 성립하는 실제 근거는 dep 등록이 생성자에 고정돼 언바인드가 아무것도 안 뗀다는 것 — 재마운트는 Destroying 연결만 다시 건다(_bindDestroying). 포탈 사이에 놓친 emit의 캐치업은 홀드 플래그로([2026-08-28 H-151/H-159]_epochs:Refresh() 폐기 — 대신 언마운트~재마운트 사이에 온 emit은 rawRerun_rerunRequired로 잡아 두고 재마운트의 _bindDestroying이 1회 돌린다).
  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회 호출됨"*과 겹쳐 바인드마다 RerunRef dep 수만큼 돌았다. 같은 문제가 State dep에도 있다 — source-state-plan.md가 확정한 "fn은 등록 시점에 즉시 1회 실행된다" 때문이다.

확정 구조 — 강한 주인은 항상 Effect, 발화 게이트는 canExecute 하나

Effect ──강──▶ _deps = { [Ref | State] = fn | Observer }   ← 강한 주인은 언제나 Effect
Ref.WeakCallbacks        ──약──▶ fn        (`ref:WeakCallback(fn)`)
  ⚠️ [2026-08-26 `/code-review high` 7차] 한때 여기 `Ref.Callbacks`(강한 셋)라
     적혀 있었다 — 그대로 읽으면 `Effect`의 클로저가 강한 셋에 들어가
     **`Ref`가 그 `Effect`를 영원히 붙들어** `H-58`의 약한 설계가 통째로 죽는다.
Observer 전역 레지스트리 ──약──▶ Observer  (`observer:WeakSubscribe()`)
발화 게이트: 전부 `canExecute(handle)` 하나로

사용자 확정(2026-08-25): "그냥 간단하게 저 강한 map 을 Effect 가 가지고, WeakSub/WeakUnsub 를 WeakCallback 처럼 넣어줍시다. 의미론은 같습니다. callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 않으면 gc 될 수 있다. 그러면 bindLifetime 은 effect 하나 구현을 한 이후 canExecute 로 모두 처리한다. 간단해집니다."

  • dep마다 바인드/언바인드에서 등록·해제하던 춤이 통째로 사라진다. bindLifetime/unbindLifetimeEffect 핸들 하나에만 적용되고, 내부 Observer와 Ref 콜백의 발화 여부는 canExecute(handle)이 전담한다.
  • 그래서 아래 H-7 절이 확정한 *"unbindLifetime:Unsubscribe()에서 :Uncallback한다"*는 더 이상 필요 없다ref-plan.md"해제는 누수를, 게이팅은 발화를 막는다"앞쪽 절반을 Weak*가 대신한다.
  • WeakRef가 아니라 WeakCallback인가(사용자 지적): "Ref 안에 항상 콜백이 쌓인다는것도 문제가 됨." 클로저가 핸들을 약하게 잡는 WeakRef는 "RefEffect를 붙든다"만 풀고 "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)

    -- (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회 호출(설치 발화)은 `fire`의 `from == nil`
    --     가드가 거른다(**[2026-08-28]** 옛 `Blocker`/`canExecute` 억제 서술은 폐기).
    --     ⭐⭐ [2026-08-26 재확정, 8라운드 `H-107`] dep 종류마다 **클로저를
    --     따로** 단다. 여기 한때 "클로저는 하나로 통일한다"고 적혀 있었으나,
    --     그 통일을 시도할 근거 자체가 없었다 — **사용자 확정**:
    --     *"Ref 의 callback 과 observer 의 콜백이 아주 헤테로지니어스한
    --     개념이라, 둘을 전혀 합치고자 한 적 없고 … observer 에는 epoch 란게
    --     존재하지 않음. emit 으로 온 epoch 를 넘겨줄 뿐, 그러나 ref 는 그
    --     자체로 epoch임."* 실제로 두 계약은 자리 수부터 다르다 —
    --     `Ref` 콜백은 `fn(value, ref)`(2번째가 곧 출처 `Epoch`),
    --     Observer는 `fn(targetState, self, emitFrom)`(3번째가 출처).
    --     ⚠️ dedup을 클로저 identity가 하는 게 아니다 — `_deps`(중복 dep 무시)와
    --     `_epochs`(다이아몬드 판정)가 한다. 그래서 클로저를 나눠도
    --     "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다
    --     (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서
    --     `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그).
    -- ⭐ [2026-08-28 확정, 10라운드 `H-150`] 사적 `Blocker`(`_blocker:On()` …
    --   `OffWithoutEmit()`)는 **제거** — 실측(10라운드 `t18`)상 어떤 경로에서도 판정에
    --   닿지 않는 죽은 부품이었다(사용자 확정: *"Effect 의 canExecute 를 보겠다는거지?
    --   그럼 그건 맞는것 같아"*). 설치 발화의 억제는 아래 `from == nil` 가드가, 실행
    --   가능 여부는 `rawRerun`이 한 곳에서 본다(**[같은 날 `H-159`]** `fire` 자신은
    --   상태를 판정하지 않는다 — 사용자: *"fire 는 그냥 rerun 을 호출해도 될것"*).
    local function fire(from)                          -- 공통 본문
        if from == nil then return end                 -- 내부 Observer의 **설치 발화**(등록 즉시 1회,
                                                       --   `emitFrom == nil` — `source-state-plan.md`)는
                                                       --   출처가 없어 `Update(nil)`을 못 한다. ⚠️ `Ref`
                                                       --   값이 `nil`인 것과 무관 — `Ref` 경로의 `from`은
                                                       --   항상 그 `ref` 객체다(`onRefFire`).
        if self._epochs:Update(from) then              -- ⭐ [`H-151`] `_epochs`가 갱신되는 **유일한** 자리 —
            self:Rerun()                               --   묶여 있든 아니든 항상. 실행 불가면 `rawRerun`이
                                                       --   `_rerunRequired`로 홀드한다(`H-159`).
        end
    end
    local function onRefFire(_, ref) fire(ref) end            -- Ref: 2번째가 출처
    local function onStateFire(_, _, from) fire(from) end     -- Observer: 3번째가 출처
    for d in pairs(seen) do    -- 스타일 통일(`pairs` 명시). **[2026-08-27 9라운드
                               --   `H-131`]** 옛 근거 *"`for d in seen`은 테이블을
                               --   호출하려 들어 죽는다"*는 **거짓** — Luau의 일반화
                               --   반복은 런타임·`--!strict` 둘 다 통과한다(실측).
        if isRef(d) then
            self._deps[d] = onRefFire                  -- ⭐ 강한 주인 = Effect
            d:WeakCallback(onRefFire)                  -- Ref 쪽은 약함
        else
            local o = d:Observer(onStateFire)
            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` 없는 키로 들어가
        --   `Update(from)`이 그 원천의 리비전을 영영 못 본다.
        if isEpoch(d) then
            self._epochs:Sync(d)
        else
            self._epochs:TrackFrom(d.valueEpochMap)
        end
    end

    -- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇).
    --     ⭐ [2026-08-28 `H-147`/`H-159`] "한 번도 안 돌았다"는 `_rerunRequired`로
    --     표시하고(초기 실행과 "실행 못 하던 중의 변경"은 같은 요구 — 사용자),
    --     본체를 `force`로 부른다. `force`의 뜻은 **하나** — *"canExecute 를 무시하고도
    --     호출할 수 있냐. 오직 그게 전부야."*(사용자). 초기 실행을 바인드로 미룰 수
    --     없다는 결정(순차 처리) 때문에 이 시점 예외 하나가 남는다.
    self._rerunRequired = true
    rawRerun(self, true)
    return self
end
  • _installing 플래그도, 그 뒤를 이은 사적 _blocker도 폐기됐다_installing은 생성자 구간만 덮어 바인드 구간을 놓쳤고(7라운드 H-58), _blocker는 그 자리에 들어왔지만 [2026-08-28 10라운드 H-150] canExecute(당시 fire 첫 줄, H-159 뒤엔 rawRerun 진입)가 이미 같은 억제를 하고 있어 한 번도 판정에 닿지 않았다 (실측 t18: drop:canExecute 3 / drop:blocker 0). H-58의 사용자 지시 ("해당 맥락의 도구인 Blocker 가 존재함 … 모든 옵저버와 callback 등록에 있어서 이를 수행해야할 것임.")는 그 전제("등록 즉시 1회가 Rerun에 닿는다")가 성립하지 않았던 것으로 정정 — 억제 주체는 Effect 핸들의 canExecute다.
  • ⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다. 사용자 판단: "Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행 결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안 되거든요. 초기 값이 못 쓰게 되는거죠." 그 따름정리로 바운딩 없이 버려지는 Effect는 UB다 — cleanup이 안 불린다. Observer와 달리 Effect는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다.
function EffectHandle:_bindDestroying(inst)
    if isRunning(self) then           -- ⭐ [2026-08-28 `/code-review`] (A)의 강제 — `fn` 안에서
        error("cannot bind an Effect from inside its own fn or cleanup", 2)
    end                               --
                                      --   `New "Frame" { self }`로 자기를 leaf에 묶는 경로도 막는다
                                      --   (`bindLifetime`은 범용이라 Effect 훅인 여기서 건다)
    self:_unbindDestroying()          -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등

    -- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점.
    self._destroyConn = onDestroying(inst, function()
        self:_unbindDestroying()
        self:_consumeCleanup()
    end)

    -- (2) 캐치업 — **`_rerunRequired`가 서 있으면 1회**. dep 등록은 이미 생성자에서
    --     끝났다. ⭐ [2026-08-28 `H-151`→`H-159`] `_epochs:Refresh()`는 폐기(`_epochs`는
    --     `fire`의 `Update(from)`에서만 갱신 — 사용자: *"우린 애초에 Refersh 를 할
    --     필요가 없는거야"*), 대신 **실행 불가 상태(안 묶임·죽음·cleanup 중)에 온
    --     변경은 `rawRerun`이 `_rerunRequired`로 홀드**해 두고 여기서 한 번 돌린다
    --     (Gate의 유보와 같은 그림 — Effect는 "한 번 다시 돌면 된다"라 불리언 하나).
    --     소진된 뒤의 재바인드도 같은 플래그(`_consumeCleanup`이 세운다). 사용자:
    --     *"'초기실행' 과 '실행 안하던 중에 바뀐것' 이 사실 같은 요소"* — 옛
    --     `_installed`는 이 플래그의 부정형이라 통합했다. gcconn 연결 **뒤**라 공개
    --     `Rerun`의 게이트를 통과한다.
    if self._rerunRequired then
        self:Rerun()
    end
end

-- ⚠️ [2026-08-28 확정, 10라운드 감사 2라운드] **`fn` 안에서 자기 leaf `inst`를
--   파괴하는 것은 UB.** `Workspace.SignalBehavior`가 `Deferred`(현재 기본)면
--   `Destroying` 콜백이 다음 리줌으로 늦춰져 경합이 없지만, `Immediate`(레거시)면
--   `fn` 실행 도중 위 콜백이 동기 발화해 `_consumeCleanup`(이미 비어 있음 — no-op)과
--   `_destroyConn` 해제만 일어나고, `fn`이 돌려준 cleanup은 저장되지만 소진할 연결이
--   사라져 **영구 미소진**이다. `fn`이 자기 생명주기를 못 바꾼다(`H-147` (A))는
--   계약의 물리판 — 사용자: *"bind/unbind 에 간접 영향을 주는건데, UB 인게 맞다는 생각"*.
--   `SignalBehavior` 구분 자체는 `base/ref-plan.md`가 소스.
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._rerunRequired = true     -- ⭐ 소진됐다 = 다음 기회에 다시 설치해야 한다(아래 캐비엇)
    if c then
        -- ⭐ [2026-08-28 확정, 10라운드 감사 2라운드] cleanup은 **세 자리**에서 돈다 —
        --   `rawRerun` 루프 머리 / `Unsubscribe()` / leaf `Destroying` 콜백. 뒤의 둘은
        --   `_running` 밖이라 cleanup 안의 `self:Subscribe()`가 가드를 지나
        --   `Unsubscribe()`가 끝나기도 전에 `fn`이 재진입했다. `_running`에 뜻을
        --   얹지 않고 **별도 플래그**로 잡는다(사용자: *"_running 으로 묶어 보는건
        --   여전히 별로 괜찮은 이유가 없음. _cleanupRunning 같은걸 넣지 말아야할
        --   이유가 없는것"*).
        self._cleanupRunning = true
        c()
        self._cleanupRunning = false
    end
end

⚠️ [2026-08-25 /code-review high 정정; 2026-08-28 H-159로 플래그 통합] "설치돼 있는가"를 _cleanup의 유무로 판정하면 안 된다 — 별도 플래그가 필요하다. 여기 한때 if self._cleanup == nil or ...라고 적어뒀는데, fn의 cleanup 반환은 선택이라(Effect(function() print("x") end, s)처럼 아무것도 안 돌려주는 게 흔한 정상 용례) _cleanup이 **항상 nil**인 Effect가 존재한다. 그러면 바인드/포탈 재마운트마다 조건이 참이 되어 fn이 다시 돌고 — 이 재설계가 없애려던 H-58(바인드마다 Rerun)이 그대로 되살아난다. 그 플래그는 2026-08-25~28엔 _installed(설치됨)였고, [2026-08-28 H-159] 지금은 _rerunRequired("fn이 돌아야 하는데 아직 안 돌았다") 하나다 — 세워지는 곳은 생성자·_consumeCleanup·rawRerun의 홀드 셋, 내려가는 곳은 rawRerunfn을 실제로 돌리는 자리 하나. _installed는 이 플래그의 부정형이라 통합했다.

[2026-08-25 신설, 7라운드 H-60; 2026-08-28 10라운드 H-147로 재정의] rawRerun(self, force) 본체 + 공개 EffectHandle:Rerun(). 지금까지 호출부만 다섯 곳이고 정의가 없었다. [2026-08-28] 사용자 지적으로 둘로 갈랐다 — "처음부터 rerun 이 're'-run 인데도 초기 실행까지 담당하고 있잖아 … rawRerun(force: boolean) 을 만들어 생성 시점과 실행 시점에서 이를 명시하는게 맞지 않아?" — 초기 설치는 아직 안 묶인 상태라 공개 진입의 게이트를 못 지나므로 호출자가 그 사실을 인자로 말한다(raw* = 검사 없는 내부 본체, Slot의 raw* 관용구와 같다).

-- 본체. force = 초기 설치(생성자) — 아직 안 묶였으니 게이트를 안 본다.
-- (이 문서의 절 순서는 개념 순서다 — 실제 파일에선 `rawRerun`·`isRunning`·
--  `resubscribeTail` 같은 `local function`이 사용처(생성자·`_bindDestroying`·네
--  진입점)보다 **앞에** 선언돼야 한다. Luau의 `local function`은 앞선 호출에서 안 보인다.)
local function rawRerun(self, force: boolean)
    if self._running then
        self._pending = true           -- 실행 중 재진입 → 지연
        return
    end
    if self._cleanupRunning or (not force and not canExecute(self)) then
        self._rerunRequired = true     -- ⭐ [2026-08-28 `H-159`/`H-160`] 실행 불가 상태(cleanup 중 /
        return                         --   안 묶임 / 죽음)에 온 요청은 **버리지 않고 홀드** — 다음에
    end                                --   묶이는 순간 1회 돈다. 버리면 "변경을 아예 보고 안 함" 경로가
                                       --   생긴다(사용자: `State<Effect>` 포탈의 언마운트 cleanup 도중
                                       --   dep이 바뀌면 재마운트가 최신값을 못 본다). leaf `Destroying`
                                       --   cleanup 안의 `self:Rerun()`/`dep:Set()`도 여기 — gcconn이 아직
                                       --   연결돼 `canExecute`만으론 못 막는다(`H-160`). `Unsubscribe` 뒤
                                       --   늦게 오는 타이머의 `Rerun()`도 홀드(재구독하면 돈다).
    self._running = true
    repeat
        self._pending = false
        self:_consumeCleanup()         -- 안에서 `_rerunRequired = true`
        self._rerunRequired = false    -- ⭐ 실제로 돈다 — 이 플래그가 내려가는 **유일한** 자리
        self._cleanup = self.fn(self)
    until not self._pending            -- 재요청이 또 오면 또 돈다(`_pending` = 실행 **중**에 온 요청)
    self._running = false
end

function EffectHandle:Rerun()          -- 공개 메소드, 무인자 — 항상 게이트
    rawRerun(self, false)
    return self
end
  • [2026-08-28 확정, 10라운드 H-147] fn도 cleanup도 자기 생명주기를 바꿀 수 없다 — 그래서 이 루프 안에는 사망 판정이 없다. 2026-08-27에 H-143으로 "fnself:Unsubscribe()(원샷 Effect)"를 지원하기로 하고 Rerun 꼬리에 wasAlive and not canExecute 판정을 넣었는데, 하루 만에 그 허용 하나에서 파생된 결함이 넷(감사 2·4라운드, H-147) 나왔고 사용자가 뿌리를 짚었다: "유저 함수가 본인을 죽이고 살린다는점 자체가 모순이였다는 문제가 나와. 처음 실행해 unsub 했는데, 아래에서 sub 해버릴 수도 있지. 이건 의도 동작일까? 게다가 unbind/bind 는 본인이 못 해. 같은 계층으로 sub/unsub 가 본인이 할 수 있어야할 이유 제공 자체가 큰 그림에서 무언가 잘못된거 아닐까?"(A) 확정: Effect의 생애는 묶은 쪽(leaf면 Instance, :Subscribe()면 그 호출자)이 소유하고, fn은 dep을 읽고 부작용을 내고 cleanup을 돌려주는 것까지다. leaf가 fn 안에서 unbind/bind를 못 하는 것과 대칭. ("나는 지원 안 할 이유가 안 보였었는데, 지금 보면 엄청난 모순이네.") 강제는 네 진입점의 _running 가드(아래 EffectHandle:Subscribe() 절). 원샷은 소유자가 밖에서 Unsubscribe하거나 나중에 Once류 슈가로 — 코어엔 없다. H-143은 소멸.

  • 재진입은 지연 재실행이다. 사용자 판단: "Effect 의 실행 안에서 뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행 하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는 상황은 react 등지에서 흔함."

  • canExecute 확인은 rawRerun 진입에서 한 번[2026-08-28 H-159] fire는 판정하지 않고 Update → Rerun만 하며, 실행 불가(안 묶임·죽음·cleanup 중)면 rawRerun_rerunRequired홀드한다(no-op이 아니다 — 다음 바인드에서 1회). 그래서 루프 안엔 판정이 없다. (한때 "fire 첫 줄에서 보고 죽은 핸들은 no-op" 이었는데 그 문장은 H-159 이전 것.)

  • error 시 UB — 그 Effect는 죽는다. 전파되고 복구하지 않는다: fn이 error하면 _running이, cleanup이 error하면 _cleanupRunning이 참으로 남아 이후 모든 재진입(Rerun·네 진입점·재바인드)이 막힌다. [2026-08-28 H-160 사용자 확정] "한번 죽는게 나오면 Effect 가 전부 죽는다가 계약으로 상향되어도 문제는 없는듯. 이미 _running 도 그러한 제약을 받으니까." — 계약으로 명문화. "에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작"(사용자). 수렴 책임은 사용자 fn에 있고 무한 루프도 UB다.

[2026-08-25 신설, 7라운드 H-65] 재바인드는 재설치, 재사용은 팩토리 패턴. 파괴로 cleanup이 소진된 Effect를 다시 바인드하면 위 (2)의 _rerunRequired가 참이라([2026-08-28 H-159]not _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.mdnative* 절과 같은 이유). quad-roblox 구현은 inst.Destroying:Connect(fn) 한 줄. 주입 op 전체 목록의 단일 소스는 base/architecture.mdEngineOps.luau 줄이다.

  • 필드 목록: _destroyConn(연결 핸들), _deps(Ref|State → 내가 건 fn|Observer, 강참조), _epochs(EpochMapRefEpoch라 균일), _cleanup, _rerunRequired(fn이 돌아야 하는데 아직 안 돌았다 — 생성 직후 / 소진 뒤 / 실행 불가 상태에 온 변경. cleanup 반환이 선택이라 _cleanup으로는 판정 못 한다; [2026-08-28 H-159]_installed의 부정형을 흡수), _running/_pending(재진입 — _pending은 실행 에 온 요청, _rerunRequired는 실행 불가 상태에 온 요청), _cleanupRunning(cleanup 실행 중 — _running과 별개, 네 진입점 가드가 둘 다 본다, [2026-08-28]), .Subscribed(공개 플래그 — canExecute가 읽는 그것, 네 진입점이 세우고 내린다, 아래 "EffectHandle:Subscribe()" 절). _refDeps/_refCallbacks/_observers/_installing_deps 하나로 대체됐고, _installing 자리에 잠깐 있던 _blocker도 [2026-08-28 H-150] 제거됐다 — 억제는 canExecute.

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()에서 같이 해제한다 — State/Source dep 쪽과 대칭이다 ([2026-08-26 표기 정정, H-114]_observers 표기를 지웠다 — 지금은 _deps 하나다. ⚠️ 다만 이 문단의 "unbindLifetime에서 해제"는 H-58이 뒤집었다 — 언바인드는 아무것도 안 떼고, 억제는 canExecute가 한다(H-150). 살아 있는 것은 "Ref에 콜백 해제 경로(:Uncallback)를 둔다"는 결론뿐이다).

[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-25 폐기, 7라운드 H-58/H-59; 2026-08-27 archive/로 이전, 9라운드 H-130] 옛 모델(_observers 배열 + bindLifetime/:Subscribe() cascade)의 원문은 archive/effect-internal-observer-cascade-reversed.md. 위 "확정 구조 — 강한 주인은 항상 Effect" 절이 정반대로 확정했고, 배너 아래 죽은 문단이 두 번 살아 있는 문장처럼 편집되는 사고가 나서(그 파일 머리) 본문에서 뺐다.

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 대칭).

[2026-08-27 확정, 9라운드 H-127 → 같은 날 (b)로 정정] 의사코드 — 네 진입점은 EffectHandle 자기 것(Observer와 같은 레지스트리·같은 canBound 게이트를 쓰되 함수 본문은 공유하지 않는다 — 아래 블록 머리 주석), Unsubscribe는 게이트를 통과한 뒤 cleanup을 덧붙인다. 아래 산문(번호 목록)은 이 블록을 풀어 쓴 것이고, 순서는 이 블록이 정본이다. 산문의 번호 순서(플래그 → cleanup → fail-fast)대로 짜면 leaf 바인딩된 핸들에 :Unsubscribe()를 불렀을 때 cleanup을 소진한 뒤에야 error가 나서 E-11이 막으려던 피해(cleanup 앞당김, _rerunRequired = true)가 이미 일어난 뒤다 — Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다.

-- ⭐⭐ [2026-08-27 확정 (b), 9라운드 `H-144` 후속 — 감사 4라운드] **`EffectHandle`은
-- 네 진입점을 자기 것으로 가진다 — Observer의 함수 본문을 배정하지 않는다.**
-- 공유하는 건 **레지스트리 두 개**(`Subscribed`/`WeakSubscribed`, 소유 모듈은
-- `Observer.luau` — `H-99`)와 **`canBound` 게이트**(`LifetimeHandle.luau`의
-- 탑레벨 함수)뿐이고, `.Subscribed` 플래그의 뜻도 같다. 여기 한때(Q4/`H-127`)
-- `EffectHandle.Subscribe = Observer.Subscribe`처럼 **함수 객체를 그대로 배정**해
-- 뒀는데, `Observer:Subscribe`의 본문이 `self:WeakSubscribe()`로 **콜론 위임**하는
-- 탓에 `self`가 `EffectHandle`이면 그 조회가 `EffectHandle`의 오버라이드로 가서
-- 재구독 꼬리가 두 번 돌고, 첫 번째는 강한 킵이 서기 **전에** `Rerun`했다
-- (로컬 `luau`로 재현; 당시엔 `fn` 안 `self:Unsubscribe()`가 허용돼 그 자리에서
-- error까지 났다 — 그 허용은 `H-147`로 폐기). **사용자 확정**: *"b가 맞아. 내 머리에서 나왔던
-- 처음 구조는 그것이였어. … '하나의 무언가가 두 일을 동작하지 않는가에
-- 유의하자' — 이것도 마찬가지야. 버그를 유발하기 좋은 포인트였고"* —
-- Observer와 Effect는 이질적 타입이라(생성 방법부터 다르다) 본문을 섞지 않는다
-- (`conventions.md`의 "설계 원칙" 절에 원칙으로 승격). 등록되는 건 **핸들
-- 자신뿐**(내부 Observer·`Ref` 콜백은 생성자에서 이미 `Weak*`로 걸려 있다,
-- `H-59`). 레지스트리 두 테이블을 `Effect.luau`가 어떻게 받는지(모듈 내부
-- export, 공개 API 아님)는 구현 세부 — 이름은 구현 시.

-- ⭐ [2026-08-27 확정, 9라운드 `H-144`] 구독 둘은 등록 뒤 leaf 재바인드
-- (`_bindDestroying`, `H-65`)와 **정확히 같은 꼬리**를 붙인다. `Unsubscribe`로
-- cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 `fn`이
-- 안 돌아, deps 없는 Effect는 레지스트리가 살려두는 죽은 핸들(누수)이 되고
-- deps 있는 것은 다음 emit까지 죽어 있었다. **[2026-08-28 `H-151`]** 한때 여기
-- `_epochs:Refresh()`를 먼저 불러 "dep이 변했으면"까지 재실행했는데 폐기 —
-- `_epochs`는 emit을 받을 때만 갱신한다(`_bindDestroying`의 같은 주석). 재구독 뒤
-- 게이트 유보가 풀리며 같은 값으로 `fn`이 한 번 더 도는 것은 **정상 재실행**
-- (사용자: *"처음 생성할 때에도 Block 되어있던게 나중에 다시 들어오는 경로가
-- 있어. 그 경우도 그냥 재실행 해주지."*). Blocker는 안 쓴다 — dep을 다시
-- 등록하지 않으므로(생성자에서 한 번, `_deps` 강참조 유지) 억제할 발화가 없다.
local function resubscribeTail(self)
    if self._rerunRequired then        -- 소진됐거나 죽어 있는 동안 변경이 홀드됐으면 1회(`H-159`).
        self:Rerun()                   --   등록 뒤라 공개 `Rerun`의 게이트를 통과한다
    end
end

-- ⭐⭐ [2026-08-28 확정, 10라운드 `H-147`] **네 진입점(과 `_bindDestroying`) 첫 줄에
-- 가드** — `fn`/cleanup은 자기 구독을 바꿀 수 없다(위 `Rerun` 정의의 (A)). 보는
-- 플래그는 둘: `_running`(`fn` 실행 중)과 **`_cleanupRunning`**(cleanup 실행 중 —
-- 감사 2라운드에서 신설, `_consumeCleanup` 참고). **`error`는 헬퍼가 아니라 각
-- 본문에서 던진다** — 헬퍼 안의 `error(…, 2)`는 헬퍼의 호출 줄(quad 내부)을
-- 가리켜 `H-104` level 계약을 어긴다(`/code-review` 지적, `H-149`와 같은 이유).
local function isRunning(self)         -- 술어만 헬퍼로 — `error`는 각 본문에서(`level 2`)
    return self._running or self._cleanupRunning
end

-- 꼬리는 항상 **등록이 전부 끝난 뒤** 한 번 — `Subscribe`는 `WeakSubscribe`를
-- 부르지 않고 등록 세 줄을 자기 안에 펼쳐 쓴다. 위임하면(콜론이든 dot이든)
-- 꼬리가 강한 킵 **앞**에서 돌거나 두 번 돈다 — 감사 4라운드가 잡은 바로 그
-- 모양이다. 게이트·메시지 분기는 Observer의 것과 같다(`lifecycle-pattern.md` (2) —
-- **[2026-08-28 `H-149`]** Observer 쪽도 같은 이유로 위임을 풀고 인라인했다).
function EffectHandle:WeakSubscribe()
    if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end  -- `H-147`
    if not canBound(self) then
        error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
    end
    self.Subscribed = true
    WeakSubscribed[self] = true
    resubscribeTail(self)
    return self
end

function EffectHandle:Subscribe()
    if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end  -- `H-147`
    if not canBound(self) then
        error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
    end
    self.Subscribed = true
    WeakSubscribed[self] = true
    Subscribed[self] = true                    -- 강한 킵이 선 **뒤에** 꼬리 한 번
    resubscribeTail(self)
    return self
end

function EffectHandle:WeakUnsubscribe()        -- 관대(`H-133`) — cleanup 안 건드림
    if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end  -- `H-147`
    if Subscribed[self] ~= nil then
        error("subscribed strongly; use :Unsubscribe()", 2)
    end
    WeakSubscribed[self] = nil
    self.Subscribed = false
    return self
end

function EffectHandle:Unsubscribe()
    if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end  -- `H-147`
    if Subscribed[self] == nil then            -- ⭐ 게이트가 **먼저** — 강하게 구독된 적 없으면
        error("not subscribed strongly; use :WeakUnsubscribe()", 2)  --   (leaf 바인딩·약한
    end                                        --   구독·미구독) 여기서 error, cleanup엔 손도
    Subscribed[self] = nil                     --   안 댄다(`E-11`).
    WeakSubscribed[self] = nil
    self.Subscribed = false                    -- 향후 재실행 차단
    self:_consumeCleanup()                     -- 통과했을 때만: 직전 cleanup 정확히 1회, `_rerunRequired = true`
    return self
end
  • WeakUnsubscribe는 cleanup을 소진하지 않는다 — 약한 구독은 "GC에 맡기는" 경로라 해제가 곧 종료 신호가 아니다. 종료 신호는 강한 구독의 Unsubscribe와 leaf 사망(unbindLifetime의 훅) 둘뿐([2026-08-28 H-147] 2026-08-27에 잠깐 "fn 안 자기 해제"가 셋째로 있었으나 그 허용 자체가 폐기됐다).

  • fn 안에서 허용되는 핸들 호출은 self:Rerun()뿐이다 — 자기 구독을 바꾸는 넷(Subscribe/WeakSubscribe/Unsubscribe/WeakUnsubscribe)은 _running 가드가 error로 막는다([2026-08-28 H-147]Rerun 정의 (A)). cleanup 안에서도 같다 — 어느 자리(Rerun 루프·Unsubscribe·leaf Destroying)에서 돌든 _cleanupRunning이 서 있어 같은 가드가 먼저 걸린다.

  • 실측(9라운드 core9.luau, t12 매트릭스 — 당시 함수 배정 형태에서의 실측이고 (b) 재작성 뒤 재실행하지는 않았다[2026-08-27 기준]; 게이트 순서는 그대로라 같은 결과가 나올 것으로 추정할 뿐, 확정 근거는 M2 구현 테스트가 될 것): leaf 바인딩된 핸들의 :Unsubscribe()가 cleanup을 건드리지 않고 error, :Subscribe():Unsubscribe()는 cleanup 1회 — 전부 기대대로.

  • :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)이 참이 되어 그 경로들이 살아난다. [2026-08-27 H-144] 등록 뒤 꼬리로 _rerunRequired → Rerun이 붙는다(위 의사코드) — 소진된 뒤의 재구독은 재설치, [2026-08-28 H-159] 생성과 Subscribe() 사이에 온 변경도 rawRerun이 홀드해 뒀다가 여기서 1회 따라잡는다(첫 구독이라도 그 사이 변경이 없었으면 no-op).
    • ⚠️ 용도는 완전히 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로 직접 들고 있고, processif 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와 같은 게이트·레지스트리 조작을 한 뒤 cleanup 하나를 덧붙인다 — Effect 계층에서 의미가 확장됨. Observer의 :Unsubscribe()는 "미래 재실행만 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, :Unsubscribe()도 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨. 순서는 위 의사코드가 정본이고 아래 번호는 그 순서 그대로다([2026-08-27 /code-review high 재정렬] 옛 목록은 플래그 → cleanup → fail-fast 순이라 leaf 바인딩된 핸들에서 cleanup을 소진한 뒤 error가 났다):

    1. 게이트 — fail-fast. 강하게 구독된 적 없는 값(leaf 바인딩·약한 구독·미구독)이면 여기서 error, cleanup엔 손도 안 댄다(E-11). [2026-08-26 정정, /code-review high 7차] 옛 "idempotent"는 폐기됐다 (base/lifecycle-pattern.md의 "(2) 전역 경로" 절 — 6차가 같은 문장을 두 문서에서 지웠는데 이 세 번째 사본을 놓쳤었다).
    2. 강한 킵 해제 + handle.Subscribed = false. 이것만으로 향후 재실행이 끊긴다 — canExecute(handle) 게이트가 곧바로 거짓이 되므로. [2026-08-25 정정, 7라운드 H-58/H-59] 옛 문장 *"state가 있으면 내부 Observer도 :Unsubscribe()해서"*는 틀렸다 — 내부 Observer는 생성자에서 :WeakSubscribe()로 걸리고 해제하지 않는다(위 "확정 구조" 절), 단수 state 전제도 ...deps로 대체됐다.
    3. 직전(또는 유일한) cleanup을 정확히 1회 호출 — leaf가 죽을 때 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. 이후 leaf가 실제로 죽어도 cleanup이 중복 호출되면 안 됨 — 새 메커니즘 불필요, 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 확정)

갭이 실재했다: 지금 Effectstate 하나만 받고, 여럿을 엮으려면 :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 도 유사한 부분." RefEpoch로 승격돼(base/ref-plan.md) EpochMap이 키로 dedup하므로 공짜로 처리된다 — 옛 _refCallbacks[ref] = cb 덮어쓰기로 먼저 건 클로저가 Ref.Callbacks에 남던 버그도 같이 사라진다.
    • 검증은 전부 생성자에서 한 번만 도므로 hot path가 아니다. error level2(사용자 입력 검증) — 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()에 바로 닿는다. [2026-08-28 H-147]구독 표면 넷은 fn 안에서 error — 2026-08-27에 H-143으로 "fnUnsubscribe(원샷)"를 잠깐 지원 대상으로 뒀으나 하루 만에 뒤집었다 (위 Rerun 정의의 (A)).
  • 최소 1회는 실행된다 — React useEffect와 동일. 아직 안 채워진 Ref가 섞여 있어도 그대로 돈다(사용자: "최초 1회에서 어차피 if 로 확인해내게 될것이므로 괜찮음"). "전부 채워질 때까지 대기"는 안 한다.
  • Ref 의존성의 발화 시점은 Set될 때뿐이다(Ref는 반복 재설정이 가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.
  • 최초 1회를 한 번만 돌리는 장치: 의존성마다 구독을 걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에 한 번만 실행한다. [2026-08-25 정정, 7라운드 H-58; 2026-08-28 10라운드 H-150 재정정] 그 억제는 Effect 내부 플래그(_installing)도 사적 Blocker도 아니라 Effect 핸들의 canExecute가 한다. 여기 한때 *"[2026-08-21 확정] 이건 Effect 내부 플래그로 한다"*고 적혀 있었고 그 플래그는 생성자 구간만 덮어 바인드 구간을 놓쳤다(H-58). 그 자리에 self._blocker:On():OffWithoutEmit()이 들어왔는데, 생성자 안의 핸들은 아직 어디에도 안 묶여 있어 canExecute([H-159] 지금은 fire가 아니라 rawRerun 진입에서 본다)가 설치 발화를 전부 떨어뜨리므로 _blocker한 번도 판정에 닿지 않았다 (실측 t18). 위 생성자 의사코드가 소스다. _installing_blocker도 폐기된 필드다. (2026-08-21에 Gate 재사용을 접었던 근거 — "설치 구간엔 어떤 Set도 안 일어나 게이트에 쌓이는 소스가 없다", base/gate-plan.md의 8번 — 는 그대로 유효하다 — 그래서 게이트도, 결국은 Blocker도 아닌 canExecute 하나로 족하다.)
  • [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 fn은 한 번만 돈다 — Effect가 자기 EpochMap을 하나 든다. 갭은 실재했다: A → b, A → c, Effect(fn, b, c)에서 A:Set() 한 번에 b가 자기 observer를, c가 자기 observer를 각각 정당하게 깨워 fn이 두 번 돌았다. State 층 dedup으론 안 접힌다 — bc는 서로 다른 노드라 접어줄 공통 하류가 없고, 둘 다 §4의 1번 규칙에 정당하게 걸린다(base/state-epoch-plan.md). 위의 "설치 구간 억제"도 이건 안 덮는다(그건 등록 시점만).
    • 확정된 해법: EffectHandleEpochMap하나 들고, 각 내부 Observer의 클로저가 받은 from으로 그걸 Update한다. true일 때만 fn을 부른다. Effect가 곧 그 dep들의 공통 하류가 되므로, 한 파동에 몇 개가 깨우든 첫 번째만 통과한다.

      [2026-08-26 폐기, 8라운드 H-107/Q2-후속] 아래 "공통으로 거는 클로저" 한 벌은 옛 모델이다. dep 종류마다 콜백 계약의 자리 수가 다르므로(Reffn(value, ref)로 출처가 2번째, Observer는 fn(targetState, self, emitFrom)로 3번째) 하나로 못 합친다 — onRefFire(_, ref) / onStateFire(_, _, from) 둘로 갈라졌다. 확정 의사코드는 위 "확정 구조 — 강한 주인은 항상 Effect" 절의 생성자 블록이 소스. 이 절이 확정한 것 중 살아 있는 것은 EpochMap 하나로 다이아몬드를 접는다는 결론과, 바로 아래 ⚠️의 순서 제약뿐이다(그 본문은 지금 fire(from) 공통 함수 안에 그대로 들어가 있다).

      -- ⛔ 옛 모델(2026-08-21). 지금은 dep 종류별로 클로저가 둘이다.
      function(self, from)
          if not canExecute(handle) then return end   -- 발화 게이트
          if handle._blocker:IsOn() then return end   -- 등록 구간 억제 (⛔ `_blocker`는 `H-150`으로 제거)
          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-28 H-150H-159] 지금은 fireUpdate먼저 하므로 설치 발화는 명시적 from == nil 가드가 Update 앞에서 거른다(같은 제약, 자리만 바뀜).
    • [2026-08-25 정정, 7라운드 H-58] Ref 의존성도 이 맵에 낀다 — 여기 한때 *"RefEpoch가 아니고 :Callback으로 발화하므로 from이 없다"*고 적혀 있었는데, RefEpoch로 승격되며(base/ref-plan.md) 공개 .Revision을 갖게 됐다. 그래서 _epochs가 State/Source/Ref같은 방식으로 담고(⚠️ [2026-08-25 정정] 한때 여기 "State/Source/ Ref를 균일하게"라 적었는데 StateEpoch가 아니다 — 등록이 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로 이전).