4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항). 범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup / quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것 (Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐 받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다. 주요 확정/역전: - slot._detached lazy화, KeyGone엔 새 값 반환도 error, Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount) - Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음) - raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결. 래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸 - 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) — base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음 - Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커 분리, 4라운드 D-56 역전(archive로) - Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom), setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정 - recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던 결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈), bk.N or 0(빈 Slot 크래시) - Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦" 역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k - 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로 바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목) 새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료), state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성 결정이라 M3 전 결론 필요). 감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순, 그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는 M3에서"로 남아 있던 사각지대. doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md (A~K절, 마지막이 최신). Co-authored-by: qwreey <me@qwreey.moe>
22 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, [])와 동형).
state 지정 시(2026-08-07 여섯 번째 세션 확정): Effect는 내부적으로
state:Observer(...)를 감싸는 걸로 구현 — fn은 포지셔널 인자로 state를
받고(fn(state), :Compute의 fn(self) 포지셔널-self 패턴 재사용,
모듈화 목적 — 클로저 캡처 없이 fn을 독립적으로 정의/재사용 가능
— [2026-08-13 13차 세션 해소] 이 fn(state)가 lazy State 핸들을
받는다는 전제는 확정 유지. 한때 question.md 0-Y가 Effect를 영향
대상으로 지목했으나, 실측 결과 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(state)를 만들어 반환하는 패턴,Modifier의Boldify(10)커링 관용구(modifier-plan.md8번)와 같은 결.state:Observer(fn)도 동일하게 커링 스타일을 권장 대상으로 같이 문서화(아래 Observer 절 참고) — 모듈화가 필요하면 둘 다 이 패턴을 쓸 것.- 재실행이 필요 없는 케이스와 혼동하지 말 것: 값 변화와 무관하게 설치+최종
정리만 필요하면
state없이Effect(fn)을 씀 —state를 굳이 넘겨서 재실행을 유발할 필요 없음.
children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 그대로 재사용(그 leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백 호출). 비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분.
동적 경로 가드 — k 무관 매치, HANDLER_PRIORITY_FALLBACK
(2026-08-14 열한 번째 세션, PreRef/Observer와 같은 패턴, base/ source-state-plan.md의 "동적 경로 가드" 절 참고.) 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 열한 번째
세션, 재확인 후 명시화):
EffectHandle은 내부 Observer를 필드로 강참조 —handle._observer = observer(state가 주어진 경우만 존재). 이건 GC 방지가 목적이 아니라 (그건 아래bindLifetime/gchold가 담당):Unsubscribe()/bindLifetimecascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것.bindLifetime(inst, handle)은state가 있는 경우 내부 Observer도 같은inst로bindLifetime(inst, handle._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._observer둘 다, 또는handle._observer만으로 충분한지는 구현 세부 — 어느 쪽이든 "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가 쓰는 것과 같은 강참조 레지스트리에 자신(또는state있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구).- ⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정
inst에 묶인 경우엔 leaf 부착(bindLifetime)을 쓰지:Subscribe()를 쓰지 않는 게 정상 경로.:Subscribe()를 쓰기로 했다면(top-level이든 의도적으로 다른 경우든) 반드시:Unsubscribe()로 짝을 맞춰야 함 — 강참조 레지스트리는 quad 전역의 "정리는 기본적으로 GC에 위임" 원칙의 의도적 예외라, 로컬 변수 참조를 다 놓아도(스코프를 벗어나도) GC되지 않고 계속 실행됨. 이건 quad의 다른 프리미티브 대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자 문서에 명시적으로 경고할 것(:Subscribe()를 부르는 순간부터 그 핸들의 생애주기는 전적으로 수동 관리 대상이 됨).
- ⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정
- ⚠️ [축소, 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 되어야하는듯." - ⭐ 단, 내부 Observer cascade도 그 분기 안에 있어야 한다
(5라운드
EF-5, 확인됨) —EffectHandle은 자기 자신뿐 아니라handle._observer까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup 분기 밖에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다 (handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시 같은if안에 둘 것. - [2026-08-21 기준] 남은 건 구현 시 회귀 확인뿐이고, 설계상 열린 항목이 아니다.
- 성립하는 이유: 핸들러가 이전 값(
- 왜 위험한가: leaf 바인딩 +
:Subscribe()한 핸들에서는:Unsubscribe()가 Observer의 것을 그냥 위임하지 않는다 — Effect 계층에서 의미가 확장됨. Observer의:Unsubscribe()는 "미래 재실행만 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라,:Unsubscribe()도 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨:state가 있으면 내부 Observer도:Unsubscribe()해서 향후 재실행을 끊고,- 직전(또는 유일한) cleanup을 정확히 1회 호출 — leaf가 죽을 때 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것.
- 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로 합치지 않는다.- 인자 모양은
:Compute(fn, ...deps)의 선례 그대로 — trailing deps를 lazy 위치 인자로 콜백에 넘긴다(base/source-state-plan.md의 "trailing deps를fn에 lazy positional 인자로도 노출" 절). 새 규칙이 아니다. - 최소 1회는 실행된다 — React
useEffect와 동일. 아직 안 채워진Ref가 섞여 있어도 그대로 돈다(사용자: "최초 1회에서 어차피 if 로 확인해내게 될것이므로 괜찮음"). "전부 채워질 때까지 대기"는 안 한다. Ref의존성의 발화 시점은Set될 때뿐이다(Ref는 반복 재설정이 가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.- 최초 1회를 한 번만 돌리는 장치: 의존성마다 구독을 걸면 각 구독의 "등록
즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에
한 번만 실행한다 —
Blocker의 "state:Block()없이 직접 쓰는" 용례를 그대로 재사용(base/blocker-plan.md). ⚠️ 이 억제 장치의 정확한 모양은Gate(공용 게이트 노드) 설계에 딸려 있다 —research/gate-primitive.md가 닫힌 뒤에 확정할 것. - leaf dedup/cascade가 전부를 덮어야 한다 — 의존성이 N개면 내부 Observer도
N개라,
EffectHandle의 bind/unbind cascade와 dedup 분기가 그 전부를 같이 처리해야 한다(위E-10/EF-5와 같은 함정). 사용자 확인: "어차피 모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."
우선순위: 새 코어 메커니즘이 아니라 Effect 표면 확장이므로 M3의
Effect 구현과 같이 간다 — 다만 위 ⚠️(억제 장치) 때문에 Gate보다 뒤다.
해결됨 — 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로 이전).