`ROADMAP.md` 전문을 `base/`의 현재 확정과 대조한 전반 점검. 사용자 판단으로 마일스톤을 각주가 아니라 체크박스째 재편했다. ## 마일스톤 이동 (M3/M7 → M2) `EpochMap.luau` / `state:Gate`+`GateNode` / `Blocker.luau` / `None`+ `Dispatch/None.luau`. 넷 다 M2가 실제로 호출하는데 각주로만 예고돼 있었고, `GateNode`는 체크박스 자체가 없어 M2를 훑는 구현자에게 항목으로 보이지 않았다. `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던 전례와 같은 처리. `Blocker`는 `GateNode` 위의 정책으로 얹는다(노드를 다시 만들지 말 것). ## ⭐ 새 미결 — M2와 M3의 의존이 양방향 이동하다 드러났다. `Dispatch.setLength`가 `State<number>`, `setOffsetSource`가 `Source<number>`를 받고 `recompute`가 `offset:Set()`을 부르며, `Dispatch.drive` 자신도 배치 등록을 Blocker로 게이팅한다 — 즉 M2는 `Source.luau`/`State.luau` 없이 구현이 안 된다. 설계가 아니라 마일스톤 순서 문제라, 선택지 셋((a) 순서 교체 / (b) M2 분할 — 경계선은 `drive` / (c) 유지)을 `question.md` 2번으로 신설하고 유일한 소스로 지정했다. `HUMAN_TODO.md` 11번이 사용자 진입점. ## 모순 정정 - `Dispatch.drive` 순회 구현은 두 패스가 아니라 단일 일반화 `for`(`F-4-1`). "배열→해시 먼저"는 그 루프가 지키는 계약. 코퍼스의 "두 패스"는 본체 루프의 옛 이름으로 정리(시점 표기는 유지, 용어 각주 신설). 따름정리로 "base는 언어 동작에 안 기댄다"는 서술이 거짓이 됐고, 재작성될 스파이크 `01`이 검증할 것도 언어 동작 자체로 바뀐다 - 물리 조작 주입 op 이름은 `native*` 확정(옛 가칭 `mountInst`/`unmountInst`/ `disposeInst` 폐기). 단건 경로 순서는 `setOffsetSource` → `nativeInsert` → `setLength` → `recompute` — 역전 배너를 스스로 단 절 안에 옛 "부기 먼저" 주장이 두 문단 살아 있었다 - M2 첫 체크박스가 하강 diff와 폐기된 옛 모델을 한 불릿에서 둘 다 서술 ## 상태 표시 - `[x]`는 "짜야 할 코드"만. 설계 확정은 `### 확정된 것` 절 또는 전용 불릿으로 분리(M3/M6/M11) — 이제 `[x]`는 M0/M1에만 남는다 - M0에 `### 재검증 대기` 절 신설 — 설계 변경으로 무효화된 스파이크들이 어느 체크박스에도 없어 잊히기 쉬웠다(현황의 소스는 `luau-test/STATUS.md`) - 주입 op 개수 하드코딩을 네 문서에서 걷고 `architecture.md`의 `EngineOps.luau` 줄 하나로 단일화(그 줄에 빠져 있던 시간 op도 채움) ## 검증 `quad-doc-auditor` 10라운드(각도를 매번 바꿔 34건) + 사용자가 돌린 `/code-review high` 9건. `doc-check.py` ERROR 0. 경위와 실측된 실패 패턴은 `.claude/session/2026-08-22-01-roadmap-milestone-review.md`. Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D Co-authored-by: qwreey <me@qwreey.moe>
68 KiB
Debounce / Throttle — 시간 기반 전파 게이트
상태: base — [2026-08-19 세션, 전부 해소되어 research/에서 승격]
12절 "사용자 판단 대기"에 남아있던 마지막 항목(이름/의미론/제어 핸들/
Time = 0)까지 전부 결론이 나서 열린 결정이 없음 — 논의 원문은
session/2026-08-19-03-debounce-throttle-final-close.md. 이 라운드에서
드러난 것: 제어 핸들 설계까지 확정되고 나니 이 프리미티브는 quad-base에
새 코어 메커니즘을 추가하지 않는 순수 슈가로 귀결됨(기존 Blocker의
게이트 개념 + Ref + 주입 op 2개 위에 전부 얹힘) — 13절이 이를 반영해
갱신됨.
[2026-08-14 신설] 사용자 요청("
Blocker와 유사하게 Debounce/Throttle를 만들어야 한다")으로research/에 신설. 여기 적힌 건 에이전트가 먼저 전부 정의해본 초안이었음 — 이후 네 라운드 리뷰를 거쳐 아래 배너들대로 전부 확정됨.
[2026-08-14 1차 리뷰 반영] 사용자가 스로틀의 trailing 동작을 짚어준 뒤 세 가지가 바뀜: (1) Q5(패키지 경계) 해소 — quad-base + 엔진별 태스크 배선으로 확정(6절), (2) 초안 의사코드의 실제 버그 수정 — lodash식
MaxTime공식이 trailing 통과 직후 이중 발화하는 구멍이 있었고, "Reset한 비트" 정식화로 대체(1-1/5-3/7절), (3) 3절 발견의 범위 축소 — 파생 State 위 퇴화는Debounce만 겪고Throttle은 거의 면역임이 드러남.[2026-08-14 2차 리뷰 반영] 사용자가 주입 op의 이름/시그니처를 직접 지정:
setTimeout(func, delay) -> Timeout/clearTimeout(handle)(6절). 같이 확정/정정된 것 — (1)os.clock()은 주입 대상이 아님 (Luau 표준 라이브러리이고, Lua 5.x와 달리 일관되게 고정밀 값 — 단 절대 시각이 아니라 차이 계산 전용, 이 설계는 남은 시간만 재므로 무관), 그래서 초안이 "시계가 필요하면 세 번째 op 추가"라고 한 부분은 무효, (2) 취소를 제공 안 하는 엔진도 대응 가능(래핑 + 유효 플래그) —clearTimeout은 백엔드에 요구해도 되는 계약, (3)Timeout타입은{ __type_timeout: true, _native: any }로 확정(에이전트가 권한any는 사용자 반론으로 철회 — 6절에 뒤집힌 이유 기록), (4)MaxTime을 타이머 2개가 아니라min()하나로 줄이는 구현(6-1절).[2026-08-14 3차 리뷰 반영] 사용자가 **"emit은 항상 재전파된다"**고 지적 — 이 문서가 두 라운드 동안 "가장 중요한 발견"으로 들고 있던 3절이 전제부터 틀렸던 것으로 드러나 통째로 철회됨(파생 State 위 퇴화 없음, "
Source에 가깝게" 규칙 폐기, Q6 소멸). 대신 그 확인 과정에서base/source-state-plan.md의 무효화 dedup 문장이 확정된Observer계약과 모순되고base/architecture.md와도 어긋난다는 게 드러나, base 정정 항목(Q10)이 새로 생김.[2026-08-19 4차 리뷰 반영, 최종] 남아있던 Q1/Q2/Q4/Q8을 전부 닫음. (1) Q1 이름 —
Debounce/Throttle유지 확정, Roblox 관용 "debounce" (재진입 방지 불리언)와 다르다는 경고를 사용자 문서 첫 줄에 못박기로. (2) Q2 의미론 — (A) emit-gate로 확정, (B) value-hold는 철회.:Get()이 값을 지연시키려면 게이트가 "창이 열리기 전 캐시가 확실히 valid하다"를 보장해야 하는데, invalid로 남은 채 아무도 안 읽다가 새 창이 열리는 경우(드물지 않음 — 컴포넌트가 한동안 안 읽다가 다시 읽는 경우 등) 그 보장이 깨져 "held value" 계약이 조용히 무너짐 — 이걸 고치려면 창이 열리는 순간 upstream을 강제로 pull해야 하는데, 그건 정확히 Throttle이 막으려는 그 비싼 연산을 게이트 자신이 강제로 돌리는 셈이라 laziness와 상충. 결과적으로 Q7도 자동 소멸(A는Blocker와 완전히 같은 메커니즘이라blocker-plan.md의 기존 "Get()은 라이브 레퍼런스" 문구가 그대로 맞고 명확화가 따로 필요 없음). (3) Q4 제어 핸들 — 넣기로 확정, 다만 모양은 초안 S1/S2 둘 다 아니고 세 번째 형태로 수렴 — 5-4절 "제어 핸들" 신설 참고. (4) Q8Time = 0— 허용, "defer될 수 있음"만 문서화, 금지/에러 안 함. 부수로 Time/MaxTime을number | State<number>로 확장하는 것도 이 라운드에 같이 확정(5-2절) — 원문은 위 세션 파일.
research/operator-sugar-plan.md가 "Operator.* 카탈로그 밖의 별도
설계 질문"으로 분리해뒀던 항목이 이 문서의 출발점(그 문서 "열린 질문 —
포함 범위" 절의 Debounce/Throttle 항목). research/additional-primitives-plan.md가
2026-08-06 조사에서 "Fusion/Vide/v1 어디에도 없으니 quad도 굳이 안
만들어도 된다는 정황"으로 적어둔 판단은 이 요청으로 뒤집힘(그
문서에도 포인터를 남겨둠).
0. 세 줄 요약
- Debounce/Throttle은
Blocker와 같은 자리(무효화 전파 게이트)에 놓이고, 다른 건 "언제 열리는가"뿐 —Blocker는 사용자가:Off()로 열고, 이쪽은 타이머가 연다. 새 전파 메커니즘이 아니라Blocker가 이미 쓰는 게이트 노드의 릴리스 트리거 교체. 그리고 Debounce와 Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐(1-1절) — 공개 이름은 둘, 구현은 하나. - quad의 lazy 전파(push-invalidate/pull-recompute) 위에서도 laziness가
깨지지 않음 — 게이트는 무효화 채널만 만지고
:Get()을 절대 호출하지 않기 때문. [2026-08-14 3차 리뷰] 여기 있던 "파생 State 위에 얹으면 퇴화한다"는 발견은 전제가 틀려 철회됨(3절) — emit은 항상 재전파되므로 게이트는 중간에 뭐가 끼든 매 변경마다 신호를 받음. 대신 그 과정에서base/source-state-plan.md의 무효화 dedup 문장이 확정된Observer계약과 모순된다는 게 드러나 base 정정 항목이 생김(Q10). - 타이머는 엔진 종속이지만 부기 알고리즘은 아님 —
Tag/Attribute가 14차 세션에 밟은 길(알고리즘은 quad-base, 엔진에 손대는 마지막 한 줄만 주입)을 그대로 따라 주입 op 2개(setTimeout/clearTimeout)만 추가.Tween처럼 통째로 quad-roblox에 두는 건 근거가 다름. [2026-08-14 사용자 확정] 이 방향으로 확정 — 순수 Luau엔task자체가 없어 base가 동작하는 기본 스케줄러를 제공할 수조차 없고,Throttle도 trailing 때문에 이 배선이 똑같이 필요함(6절). 반대로os.clock()은 Luau 표준 라이브러리라 주입 대상이 아님 — 주입이 필요한 건 "미래에 실행시키는 능력"뿐이고, "얼마나 지났나"는 언어가 이미 줌(절대 시각이 아니라 차이 계산 전용, 6절 주의).
1. 왜 Blocker로는 안 되는가 (그리고 왜 그 옆자리인가)
base/blocker-plan.md의 "메커니즘 (확정)"이 정의하는 게이티드 State는
정확히 이렇게 동작함:
- 상류가 emit(무효화)하면 게이티드 노드로 전파를 시도
- 블록 중이면 전파 안 하고
HasBlockedEmit = true만 세팅 - 열릴 때
HasBlockedEmit이 true면 정확히 1회 전파하고 플래그 리셋
이건 debounce/throttle이 필요로 하는 것과 글자 그대로 같음. 차이는 딱 하나 — 여는 주체:
| 닫는 계기 | 여는 계기 | 창의 길이 | |
|---|---|---|---|
Blocker |
사용자 :On() |
사용자 :Off() |
사용자가 정함(코드 구간) |
Debounce |
상류 신호 도착 | 신호가 Time동안 없으면 |
조용해질 때까지(가변) |
Throttle |
통과 직후 | Time 경과 |
고정 |
그래서 "중복 프리미티브 아니냐"는 반문에 대한 답: Blocker는 "내가
코드로 구간을 안다"(여러 :Set()을 한 트랜잭션으로 묶음), Debounce/Throttle은
"구간을 코드가 모른다"(사용자 입력·고빈도 신호처럼 언제 끝날지 모름).
Blocker가 lexical Batch(fn)을 기각하면서 얻은 것이 정확히 "콜스택이
아니라 값으로 표현"인데(archive/batch-rejected.md), 시간 기반 게이트는
값으로도 표현할 수 없는 나머지 절반임 — 겹치는 게 아니라 상보적.
참고로 겹치는 지점이 딱 하나 있음:
Debounce{Time = 0}은 사실상 "이번 스텝의 변경을 자동으로 합쳐서 다음 스텝에 한 번만 전파"라Blocker를 손으로 여닫는 것의 자동판 근사가 됨. 이게Blocker를 대체하지는 않음(Blocker는 "정확히 이 코드 구간"이라는 결정성을 주고,Time = 0은 스케줄러 타이밍에 의존) — 다만 문서에서 두 도구를 비교 설명할 때 좋은 대조 사례.
1-1. 정확히 어떤 동작인가 — 두 도구의 차이는 "한 비트"뿐 (2026-08-14 후속, 초안 정정)
[초안 정정] 이 절은 사용자가 스로틀의 trailing 동작("1초 스로틀에서
0.0과 0.1에 입력하면, 1.0에 0.1의 값이 다시 적용되어야 한다")을 짚어준
뒤 검증하다가 초안 의사코드의 실제 버그를 발견해 다시 쓴 것. 초안은
lodash식 MaxTime(maxWait) 공식을 그대로 옮겼는데, 그 형태는 trailing
통과 직후 타이머를 전부 회수해버려서 바로 뒤에 온 신호가 "창 밖"으로
판정돼 또 즉시 발화함(1초 안에 두 번 나감). 아래 정식화는 그 구멍이
구조적으로 없음.
디바운스 — "조용해질 때까지 기다렸다 한 번". 신호가 올 때마다 타이머를
처음부터 다시 시작하고, Time동안 아무 신호도 없을 때 1회 발화.
Debounce{Time = 1}
0.0 신호 ──┐ 창 마감을 1.0으로
0.1 신호 ──┤ 취소하고 1.1로
0.3 신호 ──┤ 취소하고 1.3으로
(조용)
1.3 ● 1회 발화 (0.3 시점의 값)
스로틀 — "창 하나당 최대 한 번". 창 길이가 고정이라 신호가 창을 밀지 못함. 첫 신호는 즉시 통과(leading), 창 안에 들어온 신호는 눌러뒀다가 창이 끝날 때 최신값으로 1회 통과(trailing).
Throttle{Time = 1}
0.0 신호 ──● 즉시 통과(leading), 창 마감 1.0 고정
0.1 신호 ──┘ 눌러둠(pending), 창은 그대로 1.0
1.0 ● 0.1 시점의 최신값으로 통과(trailing) — 사용자가 짚은 그 동작
통과했으니 창을 다시 엶(마감 2.0)
1.05 신호 ──┘ 아직 창 안 → 눌러둠 (초안 버그였던 이중 발화가 여기서 막힘)
2.0 ● 통과
| 버스트 중간 | 버스트 끝 | 신호가 끊이지 않을 때 | |
|---|---|---|---|
| Debounce | 아무것도 안 함 | 1회 | 영원히 발화 안 함 |
| Throttle | Time마다 1회 |
마지막 1회 | Time마다 계속 |
"신호가 끊이지 않으면 영원히 안 함"은 버그가 아니라 디바운스의 정의
그 자체 — lodash에 maxWait가 있는 이유가 정확히 이것(아무리 계속
와도 최대 이만큼마다는 한 번 내보내라). 그래서 MaxTime은 이 새
정식화에서 디바운스 전용 안전장치로 역할이 좁아짐(스로틀은 원래
주기적으로 발화하므로 필요 없음).
⭐ 결론 — 두 도구의 차이는 딱 한 비트:
신호가 창 타이머를 리셋하는가. 디바운스는 리셋함(창이 신호를 따라 밀림), 스로틀은 리셋 안 함(창 길이 고정).
leading/trailing/통과 후 창 재개방은 완전히 동일. 그래서 공용 게이트
하나에 Reset 불리언 하나만 있으면 둘 다 나옴(7절 의사코드). lodash처럼
maxWait로 스로틀을 흉내 낼 필요가 없고, 그 과정에서 생기던 이중 발화
구멍도 없어짐.
공개 Blocker API 위에 얹어서 만들 수는 없음
"Debounce를 내부적으로 Blocker 하나 만들어 타이머로 :On()/:Off()
치는 걸로 구현하면 안 되나?"는 자연스러운 질문인데, 안 됨 — 공개
Blocker API엔 "상류 신호가 지금 도착했다"를 알려주는 통지가 없음.
타이머를 (재)시작하려면 그 순간을 알아야 하는데 알 방법이 없어서, 결국
게이트 노드 내부 훅이 필요함.
그래서 권하는 구현 방향: Blocker의 게이티드 노드를 공용 게이트 노드로
한 겹 일반화하고(= "상류 신호를 받되 전파 여부를 정책이 결정하는 State 노드"),
Blocker/Debounce/Throttle이 그 위의 서로 다른 정책으로 얹히는 형태.
새 노드 종류를 하나 더 만드는 게 아니라, 이미 하나 있는 걸 두 번째 사용처가
생겼으니 이름 붙여 꺼내는 것뿐.
[2026-08-21 실현 — 이 권고가 확정됐다] 그 노드는 state:Gate(setup)가
만드는 **GateNode**이고 M2에서 구현된다(base/gate-plan.md). 다만
"내부 공용"은 아니게 됐다 — 공개 표면이다. Debounce/Throttle 쪽
관용구는 안 바뀐다: Debounce{...}가 돌려주는 팩토리가 내부에서
s:Gate(policy)를 부르므로 state:Apply(Debounce{...})가 그대로 성립한다.
2. laziness는 안 깨진다 (확인)
quad의 전파 모델은 base/source-state-plan.md의 "전파 모델 확정" 절에서
확정된 push-invalidate(신호만) / pull-recompute(:Get()
시점)이고, 전역 원칙은 "관측해야 실체화된다"임. 시간 기반 게이트가 이걸
깨는지 확인해봤는데 — 안 깨짐:
- 게이트는 무효화 신호만 받고, 자기도 무효화 신호만 내려보냄.
:Get()을 스스로 호출하는 지점이 한 군데도 없음. - 타이머 콜백이 하는 일은 "invalid 세팅 + 아래로 전파" 뿐. 실제 계산은
여전히 소비자가
:Get()할 때만 일어남. - 즉 게이트는 eager 노드가 아님. Fusion의
timeliness="eager"류 장치를 들여올 필요 없음(그건 이미 기각된 방향).
단, 이 성질을 깨는 변형이 하나 있으니 넣지 말 것: "값이 실제로
바뀌었을 때만 통과"(distinct-until-changed)를 게이트에 섞으면 게이트가
:Get()을 호출해야 하고, 그 순간 상류 체인 전체가 eager가 됨. 필요하면
그건 별도 콤비네이터로, 그리고 그 대가를 명시적으로 문서화한 채로 다룰 것
(이 문서 범위 밖).
3. 파생 State 위에 얹으면 debounce가 throttle로 퇴화한다 → 철회됨, 전제가 틀렸음 (2026-08-14 3차 리뷰)
이 절은 이 문서에서 "가장 중요한 발견"으로 두 라운드를 버텼지만, 사용자 지적으로 전제가 무너져 통째로 철회됨. 원래 주장: 무효화 dedup 때문에 게이트가 버스트 중 두 번째 이후 신호를 못 받아,
Debounce가 "마지막 변경 후 T초"가 아니라 "첫 변경 후 T초"로 조용히 퇴화한다 — 그래서Source에 가깝게 걸어야 한다는 규칙과 열린 질문 Q6이 여기서 나왔었음. 셋 다 무효. 원문은session/2026-08-14-08-debounce-throttle-backlog.md에 보존.
무엇이 전제였고 왜 무너졌는가
전제는 base/source-state-plan.md "전파 모델 확정" 절(2026-08-14 세 번째
세션의 3단계 분할 전에는 bind-system-plan.md)의 이 한 줄이었음:
신호를 받은 State는 자기
invalid플래그만 세우고, 이미invalid였다면 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 장치
사용자 지적: "emit은 항상 재전파된다. 저 동작은 정확히 Blocker가
하는 것." 확인해보니 맞고, 저 문장은 이전 세션의 과잉 일반화로
보임. 근거 셋:
-
다이아몬드 근거가 요구하는 범위를 넘어섬. 다이아몬드 문제는
a → b,a → c,(b,c) → d에서 한 번의 변경이 여러 경로로d에 도달하는 것 — 이걸 막으려면 그 전파 파동(wave) 안에서만 중복을 접으면 됨. 그런데 저 문장은invalid를 시간에 걸쳐 유지되는 상태로 써서("이미invalid였다면"), 누가:Get()할 때까지 이후의 모든 변경까지 삼켜버림. 파동 내 dedup과 시간축 dedup은 전혀 다른 범위인데 한 문장으로 뭉뚱그려짐. -
애초에 다이아몬드 중복 재계산은 이 장치가 푸는 게 아님.
base/architecture.md가 같은 주제를 이렇게 서술함 — "전파는 push-invalidate(신호만)/pull-recompute(Get()시점) — Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림". 즉 중복 평가를 막는 건 pull-recompute 그 자체임(값은:Get()때 한 번만 계산됨).invalid플래그 dedup이 절약하는 건 재계산이 아니라 플래그 세팅 트리 순회 비용뿐 — 정확성 장치가 아니라 최적화인데,source-state-plan.md는 이걸 다이아몬드의 해결책으로 승격시켜 서술함. base 안에서 두 문서가 같은 문제의 해결 주체를 다르게 지목하고 있음. -
⭐ 확정된
Observer계약과 정면 충돌. 같은 파일이 "state:Observer(fn)" 절에서fn이:Get()을 부르지 않아도 되는 것을 명시적으로 허용함 — "재계산이 진짜 필요한지가 다른:With한 값에 따라 갈리는 경우가 있어서 (…)Get()호출 여부를 작성자가 직접 결정하게 열어둔 것". 그런데 dedup 문장을 액면대로 적용하면:source:Set(1) → state invalid → Observer 발화 → fn이 :Get() 안 함 → state는 invalid로 남음 source:Set(2) → state 이미 invalid → 전파 안 함 → Observer 안 울림 ❌ source:Set(3) → 마찬가지 ❌ ... 영원히즉
:Get()을 안 하는 Observer는 딱 한 번 울고 영구히 침묵함. 문서가 정당한 사용법으로 허용한 것이 문서의 다른 문장 때문에 조용히 깨지는 것이므로, 취향 문제가 아니라 base 내부의 실제 모순임.
사용자의 "저건 Blocker가 하는 것" 지적도 정확함 — HasBlockedEmit이
바로 "블록 중엔 여러 emit을 하나로 접어뒀다가 열릴 때 1회"이고,
Blocker는 그걸 명시적으로 켜고 끄는 opt-in 게이트로 제공함. 같은
동작을 모든 State 노드에 암묵적으로 심어두면 Blocker의 존재 의의가
절반 사라짐.
그래서 정정 후 그림 (권고)
- emit(무효화 신호)은 구독자에게 전파된다. State가 이미
invalid인지는 전파 여부와 무관. [2026-08-21 갱신] "항상"은 빠졌다 —Epoch리비전 비교를 채택하면서 같은Epoch의 같은 리비전이 두 번째로 도착하면 접힌다 (base/state-epoch-plan.md).invalid로 접는 것이 금지인 건 그대로. - 다이아몬드 중복 재계산은 pull-recompute가 이미 구조적으로
막음(
architecture.md의 서술이 맞음) — 별도 장치 불필요. 한 번의 변경이 여러 경로로 같은 노드에 닿는 파동 내 중복 순회가 실측에서 문제가 되면, 그때 파동 단위(방문 집합/에포크 카운터)로 접으면 됨 — 이건 의미론에 안 보이는 순수 구현 최적화이고, 지금처럼 영속 플래그로 하면 안 됨.[2026-08-21 실현·정정] 실제로 채택된 건 파동 단위가 아니라 소스별 영속 에포크이고(그래서 "영속이면 안 된다"는 경고는invalid플래그에만 해당했던 것으로 좁혀짐), 순수 최적화도 아니라Get()이 섞인 값을 돌려주던 것을 고치는 의미론 변경이었다 —base/state-epoch-plan.md.
이 문서에 미치는 영향 — 세 개가 사라짐
- "파생 State 위에서 퇴화한다"는 함정 자체가 없음. 게이트는 중간에
무엇이 끼어 있든 매 변경마다 신호를 받으므로
Debounce가 어디서든 정상 동작함. - "
Source에 가깝게 걸어라"는 규칙 불필요.Blocker의 "파이프라인 끝에 걸어라"와의 "정확한 거울상"이라는 서술도 같이 폐기 — 예쁜 대칭이었지만 틀린 전제 위에 있었음. - 열린 질문 Q6 소멸(문서 권고 / 경고 / 에러 중 택일할 대상이 없음).
2차 리뷰에서 "이건 Debounce만 겪고 Throttle은 거의 면역"이라고
범위를 좁혔던 정정도 같이 무의미해짐 — 애초에 둘 다 안 겪음.
대신 생긴 것 — base 정정 완료 (2026-08-14, 사용자 확정)
사용자가 모델을 확정해줌:
emit은 항상 전파함.
Blocker나 emit 전파 지연요소만 이를 지연할 수 있음. 재계산 막아지는 건 맞음 — 한 곳에서Get이 되면,invalid하다면 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음invalid가 꺼짐.
[2026-08-21 후속] 위는 2026-08-14 시점 확정 원문이다. 그 뒤 Epoch 리비전
비교를 채택하면서 "항상"에 예외가 하나 생겼다 — 같은 Epoch의 같은 리비전이
두 번째로 도착하면 접힌다(base/state-epoch-plan.md). invalid로 접는 것이
금지라는 이 문단의 요지는 그대로다.
그리고 "다 다시 써야 한다"는 지시로 코퍼스 전체 정정을 같은 세션에 수행함. 정정된 모델:
invalid플래그 = "내 캐시가 낡았다"는 표시 하나뿐, 전파 제어 장치가 아님.- emit은 자기
invalid상태와 무관하게 항상 아래로 전파됨. - 중복 재계산은 pull-recompute + 노드별 캐시가 막음. 중복 통지는 안 접음(다이아몬드에서 아래쪽 Observer가 두 번 우는 건 의도된 동작).
- 전파를 지연/흡수할 수 있는 건 명시적 게이트뿐 — 지금은
Blocker, 앞으로 이 문서의 시간 기반 게이트.
고친 곳(원문·역전 근거·영향 범위 전체는
archive/invalidate-dedup-propagation-reversed.md):
base/source-state-plan.md(전파 규칙 재작성 + "다이아몬드 의존성은 무엇이
푸는가" 절 신설 + Observer 절 상호 참조), base/architecture.md,
base/blocker-plan.md("전파를 지연시키는 유일한 요소"로 위치 명문화),
reference/comparison-fusion-vide.md,
research/framework-comparison-findings.md, ROADMAP.md M0 체크리스트,
스파이크 05-store-state-diamond-propagation.luau(옛 모델을 통과 상태로
검증 중이었음 → rewrite-required/), audit/luau-test-first-run-2026-08-13.md.
부수 확인 — :With 빌더 기각은 유지됨. base/source-state-plan.md의
":With도 새 State 노드로 확정" 절 근거 2번이 폐기된 dedup 장치를
가리키고 있어 근거를 캐시 공유로 다시 씀. 근거 1·3번이 그대로
유효하고 결론(빌더 기각)도 안 바뀜 — 오히려 근거 강도는 올라감(예전
근거는 순회 비용 최적화였지만 지금은 실제 중복 계산이라서).
4. 의미론 — 지연되는 건 "전파"인가 "값"인가 — (A) emit-gate로 확정 (2026-08-19)
두 갈래를 놓고 두 라운드 동안 (B)를 권장안으로 들고 있었으나, [2026-08-19] (A)로 확정하고 (B)는 철회. 아래는 최종 결론과, 왜 (B)가 무너졌는지의 기록.
(A) emit-gate — Blocker와 완전히 동일 — 채택
게이트는 자기 invalid를 즉시 세우되 아래로 전파만 미룸. 창이 열려 있는
동안 누가 debounced:Get()하면 최신값이 나옴 — Blocker의 gated
state와 글자 그대로 같은 동작.
(B) value-hold — 값 자체가 지연됨 — 철회됨, laziness와 상충
게이트가 창이 닫혀 있는 동안 자기를 invalid로 만들지 않고 캐시된 직전
값을 들고 있다가, debounced:Get()이 지연된 값을 반환하게 하려던
안. 업계 선례(VueUse useDebounce/RxJS debounceTime)와의 일치, Blocker와의
역할 분리를 근거로 두 라운드 동안 권장안이었음.
왜 무너졌는가: 이 계약(":Get()은 항상 창 열리기 전의 held value")을
지키려면 게이트가 창이 열리는 시점에 자기 캐시가 확실히 valid함을
보장해야 하는데, 실제로는 안 그런 경로가 있음 — 직전 커밋에서
invalid = true로 세팅된 채 아무도 :Get()을 안 부르고 있다가(다운스트림이
한동안 안 읽는 경우, 예: 언마운트됐다 재마운트) 그 상태에서 새 창이
열리면, "창 안에선 invalid 세팅 안 함"이라는 (B)의 규칙이 이미 세팅돼
있던 invalid=true를 못 되돌려서 :Get()이 곧바로 최신값을 계산해버림 —
"held value" 계약이 조용히 깨짐. 이걸 고치려면 창이 열리는 순간 게이트가
upstream을 강제로 pull해 캐시를 스냅샷 떠야 하는데, 그건 정확히
Throttle이 막으려던 그 비싼 연산을 게이트 자신이 매 창마다 강제로 돌리는
것이라 laziness가 깨짐 — Throttle의 주 용례(랙 걸리는 연산 게이팅)에서
치명적.
부수로, (B)를 지지했던 업계 선례(VueUse/RxJS)도 재검토하면 그대로 옮겨올 근거가 약함 — 둘 다 push/eager 모델(Vue reactivity의 effect는 즉시 도는 push, RxJS는 애초에 eager push 스트림)이라 "값이 지연된다"가 공짜로 성립하는 세계고, quad처럼 pull-lazy가 원칙인 곳엔 그대로 안 맞는 선례였음.
(A) 채택의 부수 효과 — Q7 소멸: (A)는 Blocker의 gated state와
완전히 같은 메커니즘이라, base/blocker-plan.md가 이미 쓰고 있는
"Get()은 라이브 레퍼런스를 준다" 문구가 그대로 맞고 별도 명확화가
필요 없음(옛 Q7이 걱정했던 모순은 (B)를 택했을 때만 생기는 문제였음).
5. API 모양
5-1. 붙이는 방법 — :Apply (Operator 관용구와 동일)
research/operator-sugar-plan.md의 "왜 :Apply인가" 절이 확정한 규칙
("이름 붙여 재사용하는 콤비네이터는 전부 factory(self) 모양 + :Apply")에
그대로 맞음:
local debounced = text:Apply(Debounce{Time = 0.3})
local sampled = mousePos:Apply(Throttle{Time = 1 / 30})
Debounce{...}는 팩토리를 반환하므로 이름 붙여 재사용 가능:local search = Debounce{Time = 0.3}후 여러 state에:Apply(search).- 재사용해도 상태를 공유하지 않음 —
:Apply가 호출될 때마다 팩토리가 새 게이트 노드(자기 타이머/자기 pending 플래그)를 만듦.Blocker가 "겹치는 배치엔 각자 새 인스턴스를 만들 것"으로 네스팅을 막은 것과 달리, 여기는 재사용이 원천적으로 안전함(공유되는 가변 상태가 없으므로).
5-2. 옵션 필드
type DebounceOptions = {
Time: number | State<number>, -- 필수. 창 길이(초) — 신호마다 리셋됨
Leading: boolean?, -- 기본 false. 버스트의 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 조용해진 뒤 한 번 통과시킬지
MaxTime: (number | State<number>)?, -- 기본 nil. 신호가 안 끊겨도 최대 이 간격마다 강제 통과
Handle: Ref<GateHandle>?, -- 기본 nil. 이 인스턴스 전용 제어 핸들(5-4절)
}
type ThrottleOptions = {
Time: number | State<number>, -- 필수. 창 길이(초) — 신호가 리셋하지 못함
Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지
Handle: Ref<GateHandle>?, -- 기본 nil. 이 인스턴스 전용 제어 핸들(5-4절)
}
- [2026-08-19 확정]
Time/MaxTime은number | State<number>허용 —Leading/Trailing은 여전히 plain만(정적 정책값이라 반응형일 이유가 없음).base/tween-plan.md의 "옵션 값 모양" 절이 든 "옵션 안에 두 번째 반응 경로를 만들지 않음" 근거는 여기 안 부딪힘 — Debounce/Throttle은Time을 구독하지 않고,setTimeout을 실제로 호출하는 그 순간에만:Get()으로 값을 읽는 폴링이라 새 무효화 채널이 안 생김(Animate가 트윈 시작 시점에만 duration을 읽는 것과 같은 결).- 이미 스케줄된 타이머엔 반영 안 됨 —
setTimeout(fn, delay)로 한 번 예약된 delay는 못 바꾸므로,Time을 바꿔도 다음 창부터만 적용됨(진행 중인 창은 그대로 끝까지 감). 7절 의사코드의openWindow/cap스케줄 지점이 유일한 읽기 지점. Time을 State로 만들고 싶으면state:Apply(...)상위 구조 (State<State<T>>)가 필요하다던 옛 서술은 무효 — 그런 상위 구조 없이 바로 지원됨.
- 이미 스케줄된 타이머엔 반영 안 됨 —
Leading = true, Trailing = false→ "버스트 시작에 한 번만"Leading = false, Trailing = true→ 기본값, 일반적인 debounce- 둘 다
false는 아무 일도 안 하는 설정 → 즉시 error 권장(Slot:Add의 범위 밖 인덱스를 clamp 대신 error로 한 선례와 같은 결).
5-3. 공개 이름은 둘, 구현은 하나 — Reset 한 비트로 갈림 (2026-08-14 개정)
[초안 정정] 초안은 lodash를 따라
Throttle{Time=t} == Debounce{Time=t, Leading=true, Trailing=true, MaxTime=t}
("Throttle은 Debounce의 프리셋")로 적었는데, 1-1절에서 그 형태에
이중 발화 버그가 있다는 게 드러나 폐기. 정정된 관계는 훨씬 단순함:
Reset |
Leading |
Trailing |
|
|---|---|---|---|
Debounce{Time} |
true(신호가 창을 민다) | 기본 false | 기본 true |
Throttle{Time} |
false(창 길이 고정) | 기본 true | 기본 true |
- 공개 표면은 생성자 2개 — 사용자가 이미 아는 이름이고, 각자 다른
기본값을 갖는 게 자연스러움. "
Throttle을 쓰려면Debounce의 옵션 4개를 알아야 한다"는 프리셋 안의 단점이 사라짐. - 구현은 하나 — 내부 공용 게이트가
Reset불리언 하나로 분기. quad가 반복해온 "같은 일 하는 두 번째 경로를 안 만든다"(Effect가 deps 배열 대신:With재사용,Slot:List가 Fusion 3분할을 흡수)를 그대로 지키면서, 두 이름이 미묘하게 갈라지는 버그도 원천 차단. Reset은 공개 옵션으로 노출하지 않음 — 노출하면Debounce{Reset=false}처럼 "이름과 동작이 어긋난 물건"을 만들 수 있게 됨. 내부 파라미터로만 둘 것.MaxTime은Debounce전용으로 역할 축소 — 1-1절대로 "신호가 끊이지 않으면 영원히 발화 안 함"이 디바운스의 정의라 그 안전장치가 필요하지만, 스로틀은 원래 주기적으로 발화하므로 무의미함.
[2026-08-19 확정] 이 개정안 그대로 채택 — 이후 라운드들이 전부 이
Reset 한 비트 모델을 전제로 논의를 진행했고 별도 이의 없이 유지됨(구
Q3).
5-4. 제어 핸들 — Flush/Cancel, 개별은 Ref로 · 전체는 팩토리로 (2026-08-19 확정, 구 Q4)
결론: 핸들을 넣는다. 초안이 제시했던 두 모양(S1: 핸들 없음, S2:
Blocker와 똑같은 "재사용 가능한 외부 객체") 둘 다 아니고, 세 번째
모양으로 수렴했다 — Debounce/Throttle가 Blocker와 근본적으로 다른
지점(이전 실행이 다음 실행에 영향을 주는 상태 기계라, Blocker처럼
여러 파이프라인에 자유롭게 공유해도 안전한 물건이 아님)을 짚은 사용자
지적에서 나왔다.
왜 State 자신에 메소드를 붙이지 않는가
가장 먼저 검토했던 대안(게이트가 반환하는 State 자체에 :Flush()/
:Cancel()을 직접 붙임)은 기각됨 — 그러면 "디바운스로 만들어진 State"와
"일반 State"가 구조적으로 다른 타입이 되어(메소드 유무로 타입이 갈림),
State 계층에 조용히 서브타입 분기가 생긴다. quad가 피해온 OOP식 확장과
같은 종류의 문제라 State 자신은 손대지 않기로 함.
왜 Blocker처럼 "먼저 만들어 공유하는 외부 객체"도 아닌가
Blocker()는 의도적으로 여러 배치에 재사용 가능한 외부 객체 —
blocker:On()/Off()가 그 블로커에 배선된 모든 gated state에 동시
적용되는 게 정확히 원하는 동작. 그런데 Debounce/Throttle을 그대로
따라하면(외부 Debouncer{...} 객체 하나를 여러 state:Debounce(d)에
공유) 사용자가 지적한 문제가 그대로 재현됨 — 여러 파이프라인이 같은
내부 타이머/pending 상태를 공유하게 되어, "누구의 신호가 창을 리셋하고
누구의 값이 커밋되는가"가 불명확해진다. 5-1절이 이미 "재사용해도
상태를 공유하지 않음, :Apply가 호출될 때마다 새 게이트"로 확정해둔
것과도 정면으로 어긋남.
채택된 모양 — 개별은 Ref 아웃파라미터, 전체는 팩토리 자체
:Apply()의 기존 계약(Operator 관용구, "팩토리가 State 하나만 돌려준다")은
안 건드리고, 옵션 필드로 핸들을 곁다리로 받는다 — base/ref-plan.md의
Ref("채워지길 기다리는 빈 박스를 먼저 만들어 넘기고, 나중에 채워지면
:Callback()/.Value로 받는" 이미 확정된 패턴)를 그대로 재사용:
export type GateHandle = {
Flush: () -> (), -- 이 인스턴스만 즉시 커밋
Cancel: () -> (), -- 이 인스턴스만 pending을 버림(전파 없음)
}
local h = Ref()
local debounced = state:Apply(Debounce{Time = 0.3, Handle = h})
-- 게이트가 실제로 만들어지는 시점(팩토리 호출 시)에 h가 채워짐:
-- h.Value == { Flush = fn, Cancel = fn } -- 이 게이트 인스턴스 하나만 제어
h.Value:Flush()
-- 이 인스턴스 하나만 즉시 커밋(pending이면 창 끝을 기다리지 않고 지금 통과)
-- :Cancel()은 반대로 pending을 그냥 버림(통과 없이 타이머만 정리)
- 개별 제어: 위처럼
Handle = Ref()로 특정:Apply()호출 하나만 겨냥. - 전체 브로드캐스트: 팩토리 자신(
Debounce{...}가 돌려주는 객체)에도:Flush()/:Cancel()을 붙임 — 그 팩토리로 만들어진 모든 게이트 인스턴스에 한 번에 적용(저장 버튼 하나로 여러 debounce된 입력을 동시 커밋하는 식의 용례). 새 객체 종류를 만드는 게 아니라 이미Debounce{...}가 돌려주던 팩토리 값에 메소드 두 개를 더하는 것뿐.- 팩토리는 자기가 만든 게이트를 weak 레지스트리로만 추적 — strong
참조로 붙잡으면 다운스트림이 전부 죽어도 게이트가 팩토리에 살아있다는
이유로 GC가 안 돼
base/lifecycle-pattern.md의 "정리(retract)는 기본적으로 GC에 위임" 절과 충돌함. weak 등록이면 그 문제가 없음(코퍼스가 gcconn/gchold 구분에서 이미 쓰는 것과 같은 종류의 장치). Flush/Cancel은 게이트 인스턴스가 이미 갖고 있는 커밋/취소 내부 함수(7절의onWindowEnd/타이머 정리 로직)를 그대로 호출 — 새 로직이 아니라 노출 방식만 다름.
- 팩토리는 자기가 만든 게이트를 weak 레지스트리로만 추적 — strong
참조로 붙잡으면 다운스트림이 전부 죽어도 게이트가 팩토리에 살아있다는
이유로 GC가 안 돼
- 의미:
Flush()는pending이면 창 끝을 기다리지 않고 즉시onWindowEnd가 하는 커밋(passThrough + 창 재개방)을 강제 실행,pending이 없으면 아무 일도 안 함(idempotent).Cancel()은 타이머를 정리하고pending = false로 되돌리되 전파는 안 함(버림).
6. 패키지 경계 — quad-base + 주입 op 2개 (사용자 확인됨, 2026-08-14)
[2026-08-14] 이 절의 방향은 사용자가 직접 확인함 — "기본 구현은 quad-base에 있는데, 엔진 따라 해당 태스크 부분만 배선해주면 되도록 만들어져야 한다 생각함". 아래는 그 결정의 근거와 구체 형태.
사용자가 같이 짚은 사실:
task는 Roblox 전용 전역이지 Luau 언어의 일부가 아님 — 순수luauCLI엔task.delay/task.wait가 아예 없고, 애초에 이벤트 루프 자체가 없음. 즉 quad-base는 "동작하는 기본 스케줄러"를 제공할 수가 없음(제공할 원시 재료가 없음). 그래서 base가 갖는 건 알고리즘 + 인터페이스이고, 미배선 상태에선addTag/setAttribute계열 엔진 op과 같은 관례대로 명확한 에러를 내는 스텁이어야 함.Throttle역시 trailing 때문에 "나중에 처리해준다"가 반드시 필요하므로 디바운스와 똑같이 이 배선에 의존함 — 스로틀만 타이머 없이 되는 게 아님.
왜 Tween처럼 통째로 quad-roblox에 두지 않는가
base/tween-plan.md의 "패키지 경계" 절이 Tween을 quad-roblox에 둔 건
Tween이 TweenService라는 엔진 기계 자체(보간 엔진, easing style,
per-instance Tween 객체)에 의존하기 때문. Debounce/Throttle이 엔진에서
필요로 하는 건 시계 하나뿐이고, 나머지(pending 플래그, 타이머 리셋,
leading/trailing 판정, MaxTime 부기)는 전부 순수 로직임.
이건 2026-08-13 열네 번째 세션이 Tag/Attribute에서 내린 판단과 정확히
같은 상황 — 부기 알고리즘을 백엔드마다 복제하지 않기 위해 알고리즘은
quad-base로 옮기고 엔진에 실제로 손대는 한 줄만 주입받게 했음
(base/dispatch-core-plan.md의 "base가 소유하는 핸들러와 주입되는 엔진 op"
절). 같은 논리를 그대로 적용하면 알고리즘은 quad-base.
주입 op — setTimeout/clearTimeout (2026-08-14 사용자 지정)
base/bind-system-plan.md의 "base 유틸은 인터페이스, 실제 구현은 백엔드
팩토리가 주입" 절이 정한 경로에 2개 추가. 핸들러 op 3개
(addTag/removeTag/setAttribute)가 아니라 bindLifetime/canExecute와
같은 "base 범용 유틸" 그룹임 — 특정 핸들러가 아니라 아무나 쓰는 배관.
setTimeout(func: () -> (), delay: number): Timeout
clearTimeout(handle: Timeout): ()
- 이름/인자 순서는 사용자 지정 — JS 관례대로 함수가 먼저. 케이싱은
탑레벨 소문자 유틸 관례(
bindLifetime/canExecute/unbindLifetime)와 일치(base/architecture.md의 "코드 스타일 — 네이밍 케이싱" 절). - 왜
task.delay/task.cancel을 안 따라갔는가 (사용자 근거) —task는 표준이 아니고 Luau의 것도 아닌, 한 엔진의 것임. base는 "누가 실제로 그려주는지 모르는" 층이라(base/module-lifecycle-plan.md) 특정 백엔드의 어휘를 그 층에 새기면 그 엔진만 특별대우하는 셈이 됨. 그래서 가장 대중적이고 엔진 중립적인 JS 어휘를 가져옴 — 어느 백엔드 작성자가 봐도 즉시 알아보는 이름. (14차 세션이 엔진 op를addTag/setAttribute로 정할 때 RobloxCollectionService와 웹className/data-*양쪽에 걸치는 이름을 고른 것과 같은 결.) - ⚠️ 그 대가로 생기는 구현 함정: Roblox
task.delay(duration, fn, ...)는 반대로 시간이 먼저라, 배선할 때 인자가 뒤집힘. 래퍼에서 한 번 뒤집어주는 게 전부지만 조용히 틀리기 좋은 자리 — 이름을 중립으로 두기로 한 이상 감수하는 비용이고, 배선 지점이 백엔드당 한 곳뿐이라 감당 가능. - 가변인자(
...)는 일부러 안 받음 —task.delay는fn에 넘길 추가 인자를 받지만, 게이트의 콜백은 게이트당 하나씩 만들어져 재사용되는 안정된 클로저(onWindowEnd)라 호출마다 새로 만들 필요가 없음. 즉 varargs로 아낄 할당이 애초에 없어서 표면만 넓히는 셈. - 미주입 백엔드에서는 base 스텁이 명확한 에러 —
addTag/setAttribute계열 엔진 op과 동일한 관례([2026-08-22] 여기 "엔진 op 3개"라고 세어 놨었는데, 정작 이 문서가 추가하는setTimeout/clearTimeout자신이 그 개수를 늘리는 쪽이라 자기모순이었다. 주입 op 전체 목록의 소스는base/architecture.md의EngineOps.luau줄. 주의:native*계층은 이 관례의 예외로, 미주입이 에러가 아니라 조합 폴백이다). 순수 Luau엔task도 이벤트 루프도 없어 base가 "적당한 기본값"을 만들어낼 수 없음. Debounce/Throttle둘 다 이 배선이 있어야 동작함 — 스로틀도 trailing 발화가 "창 끝에 다시 처리"라 태스크가 필수. 배선 안 된 백엔드에서Throttle만 되는 식의 반쪽 동작은 없음.- 이 두 op는 게이트 프리미티브 전용이 아님 — 나중에 타이머가 필요한
다른 것(예: 10절의
Sample, 백엔드 중립 테스트 하네스)이 생기면 같은 배선을 재사용. 그래서 이름도Debounce에 안 묶고 일반적으로 둠.
Timeout 핸들의 타입 — 전용 Timeout 타입으로 확정 (2026-08-14 사용자 결정)
[정정] 에이전트가 any를 권했다가 사용자 반론으로 뒤집힘. 뒤집힌
이유가 명확해서 그대로 기록:
- 에이전트 논거 1(선례)이 틀렸음.
bindLifetime(inst: any, value: any)/canExecute(value: any)가any인 건 거기엔 진짜로 아무거나 오기 때문임(사용자가 바인드하려는 임의의 값). 반면setTimeout/clearTimeout은 자기가 만들어낸 것만 주고받는 닫힌 루프라 성격이 정반대 — 같은 선례로 묶을 수 없음. - 논거 2(타입 안전이 사줄 게 없음)도 약함. 닫힌 루프이기 때문에 오히려
clearTimeout(1)같은 걸 타입 에러로 잡아줄 수 있음 —any로 두면 그 공짜 검사를 스스로 꺼버리는 셈. - 논거 3(비용)은 사용자가 더 나은 구현으로 무력화.
Relate를 걸 게 아니라 네이티브 핸들을 그냥Timeout테이블의 필드에 넣으면 됨 (타입은 캐스트로 맞춤). 그러면 릴레이션 층이 통째로 사라지고 남는 건 테이블 1개 할당인데, 애초에task.delay가 코루틴을 하나 만드는 호출이라 작은 테이블 하나는 그 옆에서 노이즈 수준임. 즉 핫패스 논거가 성립 안 함. - 런타임에 마커 필드를 넣는 것도 무방(사용자 확인) — 그러면
analyze/런타임 불일치도 없고
isTimeout판별이 공짜로 따라옴.
확정 형태 (페이로드 자리를 미리 주는 것까지 사용자 동의):
export type Timeout = {
__type_timeout: true, -- 판별 마커. 런타임에도 실제로 넣음
_native: any, -- 백엔드 전용 페이로드. base는 절대 안 읽음
}
- 마커 필드가 필요한 이유는 사용자가 짚은 그대로 — Luau에서
type Timeout = {}는 구조적으로 모든 테이블과 호환이라 아무 테이블이나 통과해버림.true싱글턴 타입이 이걸 사실상 nominal하게 만들어,clearTimeout(1)이나 엉뚱한 테이블을 타입 단계에서 잡아줌. _native를 타입에 미리 선언해두면 백엔드가:: any캐스트 없이 그냥 대입할 수 있고,any탈출이 필드 하나에 갇혀 경계가 문서화됨. 호출 지점마다 캐스트를 흩뿌리는 방식은 나중에 누가 다른 필드를 더 끼워넣어도 아무도 모르는 게 문제였음 — quad가 타입 검사를 조용히 끄는 걸 싫어해온 것(base/typing-limits.md)과 결이 맞는 쪽으로 정리됨._접두사는 코퍼스의 기존 private 필드 관례(handle._observer,slot._mountedInst,_fired)와 일치._native에 뭘 담을지는 전적으로 백엔드 자유 — coroutine 하나일 수도, 취소 플래그를 담은 테이블일 수도 있음(아래 "취소를 제공하지 않는 엔진" 절). base는 이 필드를 읽지도 쓰지도 않고 그저setTimeout이 준 값을clearTimeout에 되돌려줄 뿐임.
백엔드 구현(quad-roblox):
function setTimeout(func: () -> (), delay: number): Timeout
return {
__type_timeout = true,
_native = task.delay(delay, func), -- ⚠️ 인자 순서가 뒤집힘(위 참고)
}
end
function clearTimeout(timeout: Timeout)
task.cancel(timeout._native)
end
Brand 편입은 불필요해 보임 — base/brand-plan.md의 Brand는
사용자가 값 종류를 판별하는 용도인데, Timeout은 사용자 표면에 안 나오는
배관임. 마커 필드 하나로 clearTimeout이 자체 검사하는 걸로 충분.
취소를 제공하지 않는 엔진 (사용자 제기, 답 있음)
"엔진이 cancel을 안 주면?"에 대한 답은 "그래도 구현 가능" — 프로바이더가 함수를 래핑하고 유효 플래그를 밖에서 뒤집으면 됨:
-- 네이티브 취소가 없는 엔진의 프로바이더 구현 스케치
function setTimeout(func: () -> (), delay: number): Timeout
local cancelled = false
engineDelay(delay, function()
if not cancelled then func() end
end)
-- _native의 내용물은 백엔드 마음 — 여기선 취소 클로저를 담음
return { __type_timeout = true, _native = function() cancelled = true end }
end
function clearTimeout(timeout: Timeout) timeout._native() end
즉 clearTimeout은 base가 백엔드에 요구해도 되는 계약임(못 지킬 엔진이
없음). 다만 이 방식은 스레드/타이머가 실제로는 예정대로 깨어나서 아무
일도 안 하는 형태라, 8절의 "대기 타이머가 게이트를 붙잡는다"는 성질은
그대로 남음(여전히 유계라 문제는 아님).
os.clock()은 주입 대상이 아님 (2026-08-14, 사용자 정보로 정정)
초안은 "시계가 필요해지면 now()를 세 번째 주입 op로 추가"라고 적어뒀는데,
그럴 필요가 없음 — 사용자 지적대로 os.clock()은 Luau 표준
라이브러리이지 task처럼 Roblox가 얹은 전역이 아님. 게다가 Lua 5.x가
리눅스에서 "프로세스가 소비한 CPU 시간"을 주는 것과 달리 Luau는 일관되게
고정밀 값을 주므로, base가 백엔드와 무관하게 그냥 불러 쓸 수 있음.
⚠️ 단,
os.clock()은 "현재 시각"이 아님(사용자 재지적) — 기준점이 정해지지 않은 고정밀 카운터라서 두 값의 차이(diff)를 재는 용도로만 유효함. 절대 시각으로 해석하거나, 저장해뒀다가 다른 시간 개념(os.time()등)과 비교하면 안 됨.이 게이트 설계는 이 제약을 원래 안 건드림 — 쓰는 곳이 전부
maxDeadline = os.clock() + MaxTime잡아두고 나중에maxDeadline - os.clock()으로 남은 시간을 구하는, 순수한 차이 계산뿐임. 절대 시각이 필요한 자리가 한 군데도 없음. (부수 효과로 벽시계 보정(NTP·사용자 시간 변경)에 영향받지 않는다는 장점도 있음 — 타이머엔 오히려 이쪽이 맞음.)
정리: 주입이 필요한 건 "미래에 뭔가를 실행시키는 능력"(setTimeout/
clearTimeout)뿐이고, "얼마나 지났나"(os.clock 차이)는 언어가 이미 줌.
그래서 아래 6-1의 최적화들이 주입 표면을 늘리지 않고 가능해짐.
6-1. 구현 세부 — 취소+재스케줄 vs 지연 타이머
기본안은 신호마다 clearTimeout + setTimeout(단순, 정확). Roblox에서
이건 신호마다 스레드 하나를 만들고 버리는 것이라, 텍스트 입력(초당 ~10회)
수준에선 무시해도 되지만 Heartbeat 같은 고빈도 소스에 많은 노드가 붙으면
스레드 처닝이 될 수 있음.
대안: 타이머를 취소하지 않고, 콜백에서 os.clock()으로 "진짜 마감이 더
뒤인가"를 보고 남은 시간만큼 다시 스케줄(고전적인 lazy timer). 위 절대로
os.clock()은 주입 없이 그냥 쓸 수 있으므로 인터페이스 변경이 전혀
없음 — 순수 내부 최적화라 나중에 전환해도 안전함. 지금은 단순한
쪽으로 가고, 실측에서 문제가 드러나면 그때 전환을 권함.
MaxTime을 타이머 2개가 아니라 1개로 (사용자 제기)
사용자가 짚은 두 갈래 — (a) 버스트 시작 시각을 os.clock()으로 잡아두고
계산, (b) 시작할 때 "리셋되는 타이머"와 "MaxTime 타이머" 둘을 걸기 —
중 7절 의사코드는 (b)를 씀(시계 없이 닫히고 읽기 쉬워서). 다만 os.clock()을
그냥 쓸 수 있게 된 이상 둘을 합쳐 타이머 1개로 줄이는 게 더 나음:
-- 버스트 시작 시: maxDeadline = os.clock() + MaxTime
-- 매 신호마다: 타이머 마감 = min(Time, maxDeadline - os.clock())
-- ↑ 둘 다 "남은 시간"(차이)이라 os.clock()의
-- 기준점이 무엇이든 무관 — 위 주의 참고
한 타이머가 "조용해짐"과 "더 못 기다림" 둘 다를 표현하므로, 어느 쪽으로
깨어나든 그냥 커밋하면 됨. MaxTime이 없으면 maxDeadline이 무한대라
min이 항상 Time이 되어 자연히 같은 코드로 흡수됨(분기 없음).
의사코드를 (b)로 남겨둔 이유: 두 타이머가 각각 무슨 뜻인지가 눈에
보여서 검토하기 쉬움. 실제 구현은 위 min 형태를 권함 — 동작은 동일하고
타이머/스레드 수가 절반.
7. 의사코드
주의:
Gate노드의 내부 훅(onUpstreamSignal,commit)은 아직 이름도 계약도 확정되지 않은 가칭 —Blocker의 게이티드 노드가 이미 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것. 아래 코드의registry/Handle배선도 마찬가지로 스케치 수준 — 실제 구현 시 이름은 자유.
-- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절).
local function readTime(t: number | State<number>): number
if type(t) == "number" then return t end
return t:Get() -- setTimeout 호출 시점에만 읽음 — 이미 스케줄된 타이머엔 영향 없음
end
local function makeGate(reset: boolean, opts)
if opts.Leading == false and opts.Trailing == false then
error("Leading/Trailing 둘 다 false면 아무것도 통과하지 않음")
end
local leading = if reset then opts.Leading == true else opts.Leading ~= false
local trailing = opts.Trailing ~= false
-- 팩토리 레벨 상태 — makeGate 호출(=Debounce{...}/Throttle{...} 한 번)당 하나.
-- weak 레지스트리라 여기 등록돼도 게이트의 GC를 막지 않음(5-4절).
local instances = setmetatable({}, {__mode = "k"})
local function flushAll()
for gate in instances do gate._flush() end
end
local function cancelAll()
for gate in instances do gate._cancel() end
end
-- 팩토리 자체 — :Apply(factory)가 호출할 수 있는 함수이면서, 동시에
-- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 콜러블 객체(5-4절)
local factory = setmetatable({}, {
__call = function(_, self)
local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드
local pending = false -- 창 안에서 상류 신호가 있었는가
local window = nil -- 살아있으면 "창 안", nil이면 idle
local cap = nil -- MaxTime 타이머(Debounce 전용)
local openWindow, onWindowEnd
function openWindow()
window = setTimeout(onWindowEnd, readTime(opts.Time))
end
function onWindowEnd()
window = nil
if pending and trailing then
-- Blocker와 같은 순서: 상태를 먼저 정리하고 그 다음 전파
-- (전파 도중 소비자가 동기적으로 상류를 :Set()해서 재진입해도
-- 방금 닫은 창의 잔여 상태를 다시 건드리지 않게)
pending = false
if cap then clearTimeout(cap); cap = nil end
gate:passThrough() -- invalid 세팅 + 아래로 1회 전파
openWindow() -- 통과했으니 창을 다시 엶 = 다음 통과까지 최소 Time
end
-- pending이 없으면 창을 안 열고 완전히 idle로 복귀
end
gate.onUpstreamSignal = function()
if window == nil then
-- 창 밖(idle)
if leading then gate:passThrough() else pending = true end
openWindow()
else
-- 창 안
pending = true
if reset then -- ← Debounce만: 창을 뒤로 민다
clearTimeout(window)
openWindow()
end
end
-- MaxTime: 창과 달리 절대 리셋되지 않는 두 번째 타이머.
-- "신호가 안 끊기면 영원히 발화 안 함"(1-1절)을 위한 안전장치라
-- Debounce에서만 의미 있음.
if opts.MaxTime and cap == nil and pending then
cap = setTimeout(function()
cap = nil
if pending and trailing then
pending = false
if window then clearTimeout(window) end
gate:passThrough()
openWindow()
end
end, readTime(opts.MaxTime))
end
end
-- 5-4절 제어 핸들 — Flush는 즉시 커밋(창 끝을 안 기다림), Cancel은 버림
gate._flush = function()
if pending and trailing then
pending = false
if window then clearTimeout(window) end
if cap then clearTimeout(cap); cap = nil end
gate:passThrough()
openWindow()
end
end
gate._cancel = function()
pending = false
if window then clearTimeout(window); window = nil end
if cap then clearTimeout(cap); cap = nil end
end
instances[gate] = true
if opts.Handle then
opts.Handle:Set({ Flush = gate._flush, Cancel = gate._cancel })
end
return gate
end,
__index = { Flush = flushAll, Cancel = cancelAll },
})
return factory
end
function Debounce(opts: DebounceOptions) return makeGate(true, opts) end
function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end
사용자가 짚은 시나리오 검증 (Throttle{Time = 1}, 0.0과 0.1에 입력):
0.0 window == nil → leading 통과 ● openWindow → 마감 1.0
0.1 window 살아있음 → pending = true
reset == false 라 창을 안 밂 → 마감은 여전히 1.0
1.0 onWindowEnd: pending && trailing
→ pending = false, passThrough ● ← 0.1 시점의 최신값이 여기서 적용됨 ✅
→ openWindow → 마감 2.0
1.05 window 살아있음 → pending = true (초안이 여기서 이중 발화했음, 이제 막힘 ✅)
2.0 통과 ● → openWindow → 3.0
3.0 pending 없음 → 창 안 엶, idle 복귀
3.5 입력 → window == nil → leading 즉시 통과 ●
(A) emit-gate가 붙는 자리(확정, 4절): gate는 다른 평범한 State와
똑같이 매 onUpstreamSignal 진입 시 자기 invalid를 즉시 세운다 —
source-state-plan.md의 "전파 모델 확정" 절이 정한 전파 규칙([2026-08-21]
"항상"이 아니라 "같은 에포크의 두 번째만 접는다" — base/state-epoch-plan.md)이
게이트 자신에게도 그대로 적용됨. 위 코드의 gate:passThrough()가
실제로 미루는 건
다운스트림 통지(전파)뿐이지 invalid 세팅이 아님 — 그래서 창이 열려
있는 동안 gate:Get()을 불러도 항상 최신값이 계산됨(캐시가 stale한
채로 안 남음).
8. 라이프사이클 / GC 분석
base/lifecycle-pattern.md의 "정리(retract)는 기본적으로 GC에 위임"
원칙 위에서 확인한 것들:
- 대기 중인 타이머는 게이트 노드를 강하게 붙잡음. Roblox
task.delay가 콜백을 들고 있고, 콜백이 게이트를 업밸류로 캡처하므로. 즉 다운스트림이 전부 죽어도 최대Time(또는MaxTime)초 동안은 노드와 그 상류 체인, 그리고 (B)에서는 캐시된 값까지 살아 있음.- 유계이고 자가 치유됨 — 누수가 아니라 "지연된 GC". 문서화 대상.
- 위험해지는 조합은 긴
Time+ 빠른 생성/파괴(예:Slot:List항목 하나하나가Time = 60짜리 게이트를 갖는 경우). 이건 문서 경고 + (Q4에서 다루는) 제어 핸들의:Cancel()로 대응.
- 타이머 콜백이 죽은 대상을 건드릴 위험은 없음. 콜백이 하는 건 플래그
세팅과 무효화 전파뿐이고, 실제로 무언가를 하는 소비자(store-bind
핸들러/Observer)는 이미
canExecute게이트를 통과해야만 실행되므로 기존 장치가 그대로 커버함. Dispatch 쪽 변경 필요 없음. - 게이트 노드는
inst에 안 묶인 순수 값 계층이라bindLifetime/unbindLifetime배선이 필요 없음 —Slot/Effect가 겪은 복잡도가 여기엔 없음. - 두
Relate상호 순환 위험 없음 —Relate를 아예 안 씀(게이트의 상태는 전부 클로저 업밸류).base/relate-plan.md의 "위험한 패턴" 절과 무관.
9. 이름 — Debounce/Throttle 확정 (2026-08-19)
9-1. ⚠️ Roblox 커뮤니티의 "debounce"와 충돌함 — 유지로 확정
Roblox 생태계에서 debounce는 압도적으로 재진입 방지 불리언을
가리킴(local debounce = false ... if debounce then return end). 웹
쪽 의미(시간 기반 합치기)와 이름만 같고 완전히 다른 물건임. quad의 주
사용자층이 Roblox 개발자라는 걸 감안하면 이건 실질적인 혼동 위험이고,
이 코퍼스가 Brand 후보에서 Tag를 뺀 것과 같은 종류의 문제
(.claude/question.md 1번의 Brand 항목).
후보:
| 이름 | 근거 | 문제 |
|---|---|---|
Debounce/Throttle |
RxJS/lodash/VueUse 전부 이 이름, 검색성 최고 | 위 충돌 |
Settle / Settled |
"값이 가라앉을 때까지 기다린다" | 선례 없음, Promise settle과 혼동 |
Quiet / Idle |
동작을 직관적으로 서술 | 선례 없음 |
Coalesce |
"합친다"는 동작 자체 | nil 병합(Alternative)과 어휘 충돌 |
RateLimit (Throttle 자리) |
의미 명확 | 서버 레이트 리밋 뉘앙스 |
[2026-08-19 확정] Debounce/Throttle 유지 + 사용자 문서 첫 줄에
Roblox 관용 "debounce"와 다르다는 걸 못박기. 업계 표준 이름을 버리면
검색·이주 비용이 더 크다는 게 채택 근거 — 더 판단할 것 없음(구 Q1).
9-2. -ed를 안 붙이는 이유 (이건 코퍼스 규칙으로 결정됨)
Tag.Added/Modifier.Overridden/Sorted의 -ed는 "clone 후 즉시
확정된 값"이라는 관례고, lazy한 것엔 안 붙임(Compute가 Computed가
아닌 이유 — base/source-state-plan.md의 "네이밍" 절). 게이트가 반환하는
건 lazy State 노드이므로 Debounce/Throttle 원형이 맞음, Debounced
아님. 이건 열린 질문 아님.
10. 인접 후보 — 지금은 범위 밖
Delay{Time}— 합치기 없이 그냥 미룸. 게이트로 표현 가능하지만 실사용 근거가 약함.Sample{Time}(RxJSsampleTime) — 상류 변경과 무관하게 주기적으로 최신값을 통과. 이건 상류 신호가 없어도 타이머가 도는 거라 유일하게 진짜 eager가 되는 변형 — 넣는다면 별도 판단 필요.Audit(RxJS) —Throttle{Leading = false}와 같음, 프리셋으로 흡수됨.
11. 다른 결정과의 상호작용 (확인 완료)
Dispatch: 변경 없음. 게이트는 순수 값 계층이고 store-bind 핸들러는 평소처럼 무효화를 받아:Get()할 뿐.Tween: 직교.debounced:Apply(Animate{...})처럼 겹쳐 쓸 수 있고, 둘 다 시간을 다루지만 층이 다름(하나는 값 보간, 하나는 전파 타이밍).Blocker: 직교하게 겹쳐 쓸 수 있음(state:Apply(Debounce{...}):Block(b)). 실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음.Effect/Observer: 게이트 아래에 붙으면 자동으로 debounce된 빈도로 재실행됨 — 별도 장치 불필요.Effect가 deps 배열을 안 만들고:With를 재사용한 것과 같은 결로, "debounce된 Effect"라는 별도 API를 만들 필요가 없다는 뜻.- 테스트/
quad-mock: 주입 op 2개 덕분에 가상 시계로 결정론적 테스트가 공짜로 됨(스케줄 큐를 손으로 진행). 이게 6절의 quad-base 배치를 미는 또 하나의 근거 — quad-roblox에task.delay를 직접 박아넣으면 base 테스트 하네스에서 이 프리미티브를 테스트할 방법이 없어짐. typing-limits.md: 게이트는State<T> -> State<T>(타입 인자 불변)라 0-Y가 걸렸던 "자기를 다른 타입 인자로 감싸 반환"에 해당하지 않음 — 타입 쪽 추가 위험 없음. 다만:Apply결과를 받는 자리에 명시 주석 바인딩을 하라는 일반 관례는 그대로 적용.
12. 사용자 판단 대기 — 전부 해소됨 (2026-08-19)
[2026-08-19] 남아있던 Q1/Q2/Q4/Q8까지 전부 닫혀 열린 항목이 없음 —
논의 원문은 session/2026-08-19-03-debounce-throttle-final-close.md.
이력만 남겨둠(각 항목이 왜 그렇게 됐는지는 가리키는 절이 소스, 여기서
반복 안 함):
이름[2026-08-19 해소] —Debounce/Throttle유지, Roblox 관용 "debounce"와의 충돌은 문서 경고로만 대응. (9-1절)의미론[2026-08-19 해소] — (A) emit-gate 채택, (B) value-hold는 laziness와 상충해 철회. (4절)공개 생성자 2개 + 내부 구현 1개([2026-08-19 해소] — 2026-08-14 개정안 그대로 채택, 이견 없었음. (5-3절, 7절)Reset한 비트)제어 핸들[2026-08-19 해소] — 넣기로 확정. 초안의 S1(핸들 없음)/S2(Blocker와 같은 공유 외부 객체) 둘 다 아니고, 개별은Ref아웃파라미터·전체는 팩토리 자체의:Flush()/:Cancel()(weak 레지스트리로 브로드캐스트)로 수렴. (5-4절)패키지 경계[2026-08-14 해소] — 사용자가 quad-base + 엔진별 태스크 배선으로 확정. 주입 표면이 3개→5개로 늘어나는 비용은 수용됨. op 이름/시그니처도 사용자가 지정 (setTimeout(func, delay)/clearTimeout(handle)). (6절)[2026-08-14 소멸] — 전제(무효화 dedup이 신호를 삼킴)가 틀린 것으로 드러나 질문 자체가 없어짐. emit은 항상 재전파되므로 게이트는 어디에 걸든 정상 동작함. (3절)Debounce를 파생 State 위에 걸었을 때[2026-08-19 소멸] — Q2가 (A)로 확정되면서 이 항목이 걱정했던 모순(값-지연 의미론과 "base/blocker-plan.md한 줄 명확화Get()은 라이브 레퍼런스" 문구의 충돌)이 애초에 안 생김 — (A)는Blocker와 완전히 같은 메커니즘이라 그 문구가 그대로 맞음. (4절)[2026-08-19 해소] — 허용, "defer될 수 있음"만 문서화, 금지/에러 안 함. (1절 인용문)Time = 0을 허용할지[2026-08-14 완전 해소] — 사용자 결정으로Timeout핸들의 타입type Timeout = { __type_timeout: true, _native: any }(마커는 런타임에도 실제로 넣고, 백엔드 페이로드 자리도 타입에 미리 선언). 에이전트의any권고는 철회됨 —bindLifetime이any인 건 거기 진짜로 아무거나 오기 때문이고,setTimeout/clearTimeout은 자기가 만든 것만 주고받는 닫힌 루프라 성격이 정반대이며 오히려clearTimeout(1)을 타입 에러로 잡아주는 이득이 있음. 비용 논거도 무력화 —Relate대신 네이티브 핸들을 필드에 직접 넣으면 되고,task.delay가 코루틴을 만드는 옆에서 작은 테이블 하나는 노이즈. 더 판단할 것 없음. (6절)[2026-08-14 해소·반영 완료] 사용자가 "emit은 항상 전파함,base/source-state-plan.md의 무효화 dedup 문장 정정Blocker나 emit 전파 지연요소만 이를 지연할 수 있음"으로 확정하고 전체 정정을 지시 — 같은 세션에 base/reference/research/ROADMAP/ 스파이크/audit까지 전부 반영했고, 역전 기록은archive/invalidate-dedup-propagation-reversed.md. 상세는 3절.[2026-08-19 신설·같은 날 해소] — 허용. 구독이 아니라Time/MaxTime을State로 받을 수 있는가setTimeout/cap스케줄 시점의 폴링(:Get())이라 새 무효화 채널이 안 생겨 5-2절이 인용하던tween-plan.md선례("옵션에 두 번째 반응 경로를 만들지 않음")와 안 부딪힘. 이미 스케줄된 타이머엔 미반영, 다음 창부터 적용. (5-2절)
13. 우선순위 / 마일스톤 — [2026-08-19 재평가] 결국 순수 슈가로 귀결
M0 착수를 막지 않음 — 이 문서의 어떤 결정도 디스패치/State 코어 계약을 바꾸지 않음(11절에서 확인).
[2026-08-19] 옛 서술("Operator 슈가와 달리 순수 슈가가 아니라 실제
기능 갭이라 우선순위를 위로 둔다")은 이번 라운드로 뒤집힘. 제어 핸들
설계(5-4절)까지 확정하고 나니, Debounce/Throttle이 실제로 새로
필요로 하는 quad-base 코어 표면은 주입 op 2개(setTimeout/clearTimeout)
뿐이고 — 게이트 메커니즘은 Blocker가 이미 확정한 gated state 개념
위에서(1절), 제어 핸들은 이미 확정된 Ref 위에서(5-4절) 전부 얹히는 것으로
드러남. 즉 quad-base에 새 코어 메커니즘을 추가하지 않는 순수 슈가다 —
Animate/Operator.*와 같은 성격.
그래도 착수 시점이 뒤로 밀리진 않는다. 설계 자체가 실제 기능 갭에서
나온 요청(사용자가 "만들어야 한다"고 직접 지정, research/ additional-primitives-plan.md가 원래 "안 만들어도 된다"고 판단했던 걸
뒤집은 배경)이라는 사실은 안 바뀌고, 이 문서가 research/에서 base/로
승격된 것도 별개로 유효 — 달라지는 건 오직 구현 우선순위뿐이다.
순수 슈가라는 게 확인됐으니 Operator 콤비네이터 카탈로그와 같은 급으로
맨 뒤로 미뤄도 됨(다른 기능이 이걸 의존하지 않고, 없어도 다른 기능이 안
막힘).
의존성: State 코어(ROADMAP.md M3) + 백엔드 주입 표면(setTimeout/
clearTimeout) + Blocker(gated state) + Ref. [2026-08-22 정정]
각각 M2(게이트/Blocker) / M3(State 코어) / M8(Ref)에서
확정되는 것들이라 그 이후 언제든 얹을 수 있다(옛 표기는 "전부 M3/M8" —
Blocker.luau가 M2로 옮겨지기 전 기준). [정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를
공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다 — 사용자 결정
"게이팅 먼저"(Dispatch.drive의 배치 등록이 이미 그 게이팅에 의존하므로).
1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는
것은 그대로이고, 바뀐 건 언제뿐이다. 표면/이름은 아직 미정 —
base/gate-plan.md가 소스(이 문서의 1절이 그 일반화를 처음
권고한 자리로 거기 인용돼 있다). 프리미티브 자체(Debounce/Throttle 함수)는 그 위에 아무
때나 나중에 얹으면 된다.