`question.md`에 열려 있던 마지막 항목(마일스톤 경계)을 사용자가 **(a) 순서 교체**로 닫았다 — 이제 **M2 = 반응형 코어(Source/State/Store), M3 = 디스패치 엔진**이다. 결정과 기각된 선택지는 `archive/question-resolved.md`의 "마일스톤 경계" 절, 경위와 시행착오는 `session/2026-08-24-02-milestone-order-swap.md`. ## 왜 (a)인가 의존이 양방향처럼 보였지만 실제로는 한 방향이었다 — 디스패치→반응형은 *본체* 의존(`setLength`가 `State<number>`를, `setOffsetSource`가 `Source<number>`를 받고 `recompute`가 `offset:Set()`을 부름)이라 우회 불가고, 반대는 *등록 표면* 의존(핸들러 셋)이라 미루면 그만이다. 옛 순서로는 디스패치 마일스톤의 `mock 대상 테스트` 체크박스조차 State 없이 불가능했고, 반응형 코어는 순수 Lua라 `luau`만으로 단독 테스트가 된다. (b) 분할은 기존 `M2` 참조 전부를 "M2a인가 M2b인가"로 만들어 오히려 비싸고, (c) 유지는 "순서의 소스는 `ROADMAP.md`"라는 원칙을 스스로 무효화한다. ## 그냥 맞바꾸기로는 안 끝났다 - **`Brand`/`Relate`/`LifetimeHandle`(인터페이스)이 M2 앞머리 `### 공통 기반` 절로** — 반응형이 이 셋을 먼저 요구한다(`Source`가 `SourceBrand`+`EpochBrand`에 등록되고, State 전파 루프가 매 발화마다 `canExecute`를 부르며, 그 판정이 `Relate` 위에 얹힌다). 셋 다 State-free 이자 dispatch-free라 어느 쪽에도 안 걸린다. - **2026-08-22에 디스패치로 앞당겼던 `EpochMap`/`GateNode`/`Blocker`가 M2로 복귀** — 앞당길 이유 자체가 순서 교체로 사라졌다. "게이팅 먼저"는 그대로 지켜진다(게이팅이 디스패치보다 먼저 지어진다). - **Observer/Effect 동적 경로 가드 등록과 `ObserverEffectLeafHandler`는 M3로** — 둘 다 "핸들러를 등록한다"뿐이라 본체와 잘라내기 쉽다. 이것이 M2가 M3에 개념상 지던 유일한 의존이고, 미뤘으므로 빌드 순서엔 역방향 간선이 없다. - **`EpochMap`이 State 본체보다 앞으로**(감사 5라운드 발견) — State가 `valueEpochMap`/`emitEpochMap` 둘을 컴포지션하므로 옛 배치로는 `State.luau`를 못 짠다. 문서 자신이 *"아래 `Source.luau`/`State.luau`가 이걸 전제로"*라 쓰면서 그 둘이 위에 있는 형태로 증거를 남기고 있었다. ## 대가 하나 — 게이트 둘이 "바로 다음"으로 올라왔다 `question.md` 낮은 우선순위에 있던 **중간 State GC 미검증**과 **`store:GetDynamic` 위치**가 원래 반응형(옛 M3)의 게이트였는데, 반응형이 M2가 되면서 착수 직전 항목이 됐다 — 최우선 절로 승격했다. 순수 *설계* 결정 대기는 여전히 0건이다(하나는 실측 미완, 하나는 표면 위치 선택). ## 번호 재부여의 경계 라이브 문서의 `M2`/`M3` 참조 248건은 전량 새 번호로 맞췄다(코퍼스의 참조가 거의 전부 "그 내용이 사는 마일스톤"을 가리켜 기계적 맞교환으로 의미가 보존됨). **`session/`·`archive/`·`qa-request/`는 히스토리라 소급 수정하지 않았다** — 그 문서들의 `M2`/`M3`는 옛 의미(M2=디스패치, M3=반응형)이고, 이 경고를 인덱스 다섯 곳에 박아뒀다. 일괄 치환에 `\bM([23])\b`를 썼다가 Python `\w`가 유니코드라 한글이 붙은 93건(`M2는`/`M2로`)이 안 바뀌는 실수를 냈고, `git show HEAD:<경로>`로 되돌린 뒤 ASCII 경계 lookaround로 재실행했다. 그 부수로 `Relate`/ `LifetimeHandle` 참조들은 구 M2 → 신 M2로 두 번 옮겨져 **번호가 우연히 보존**됐다는 것도 드러났다(치환하면 안 되는 자리 — 감사가 잡아 되돌렸다). ## 검증 `quad-doc-auditor` 루프가 라운드마다 각도를 바꿔 **7라운드에서 새 발견 0건으로 수렴**(10→8→1→1→2→2→0). 가장 값이 큰 건 5라운드(구현 순서 시뮬레이션)로, 위 `EpochMap` 배치 오류를 잡았고 **순서 교체의 전제 자체도 검증**했다(새 M2 전체를 디스패치 심볼로 훑어 "가드 등록 둘 말고는 없음"). **수렴 뒤 사용자가 돌린 `/code-review high`가 5건을 더 잡았고 전부 유효** — 넷이 *"라벨은 치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진"* 종류였고, 그중 하나는 `research/` 안의 **히스토리 블록**이 치환을 맞아 원래 논거가 문장 그대로 거짓이 된 것이었다(소급 수정 제외 대상을 세 폴더로만 잡은 게 샜다). `conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가 또 재확인됐다. `doc-check.py` ERROR 0. Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
1202 lines
72 KiB
Markdown
1202 lines
72 KiB
Markdown
# 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) **Q8 `Time = 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. 세 줄 요약
|
|
|
|
1. **Debounce/Throttle은 `Blocker`와 같은 자리(무효화 전파 게이트)에
|
|
놓이고, 다른 건 "언제 열리는가"뿐** — `Blocker`는 사용자가
|
|
`:Off()`로 열고, 이쪽은 타이머가 연다. 새 전파 메커니즘이 아니라
|
|
**`Blocker`가 이미 쓰는 게이트 노드의 릴리스 트리거 교체**. 그리고
|
|
**Debounce와 Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한
|
|
비트뿐**(1-1절) — 공개 이름은 둘, 구현은 하나.
|
|
2. **quad의 lazy 전파(push-invalidate/pull-recompute) 위에서도 laziness가
|
|
깨지지 않음** — 게이트는 무효화 채널만 만지고 `:Get()`을 절대 호출하지
|
|
않기 때문. **[2026-08-14 3차 리뷰]** 여기 있던 "파생 State 위에 얹으면
|
|
퇴화한다"는 발견은 **전제가 틀려 철회됨**(3절) — emit은 항상 재전파되므로
|
|
게이트는 중간에 뭐가 끼든 매 변경마다 신호를 받음. 대신 그 과정에서
|
|
**`base/source-state-plan.md`의 무효화 dedup 문장이 확정된 `Observer`
|
|
계약과 모순된다는 게 드러나** base 정정 항목이 생김(Q10).
|
|
3. **타이머는 엔진 종속이지만 부기 알고리즘은 아님** — `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` —
|
|
**[2026-08-24]** 2026-08-22엔 디스패치 쪽이었다가 마일스톤 순서 교체로
|
|
반응형 코어로 돌아왔다). 다만
|
|
"내부 공용"은 아니게 됐다 — **공개 표면**이다. `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`가
|
|
하는 것."** 확인해보니 맞고, 저 문장은 **이전 세션의 과잉 일반화**로
|
|
보임. 근거 셋:
|
|
|
|
1. **다이아몬드 근거가 요구하는 범위를 넘어섬.** 다이아몬드 문제는
|
|
`a → b`, `a → c`, `(b,c) → d`에서 **한 번의 변경**이 여러 경로로
|
|
`d`에 도달하는 것 — 이걸 막으려면 **그 전파 파동(wave) 안에서만**
|
|
중복을 접으면 됨. 그런데 저 문장은 `invalid`를 **시간에 걸쳐 유지되는
|
|
상태**로 써서("이미 `invalid`였다면"), 누가 `:Get()`할 때까지 **이후의
|
|
모든 변경**까지 삼켜버림. 파동 내 dedup과 시간축 dedup은 전혀 다른
|
|
범위인데 한 문장으로 뭉뚱그려짐.
|
|
2. **애초에 다이아몬드 중복 *재계산*은 이 장치가 푸는 게 아님.**
|
|
`base/architecture.md`가 같은 주제를 이렇게 서술함 — "전파는
|
|
push-invalidate(신호만)/pull-recompute(`Get()` 시점) — **Fusion식
|
|
eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림**". 즉
|
|
중복 평가를 막는 건 **pull-recompute 그 자체**임(값은 `:Get()` 때
|
|
한 번만 계산됨). `invalid` 플래그 dedup이 절약하는 건 재계산이 아니라
|
|
**플래그 세팅 트리 순회 비용**뿐 — 정확성 장치가 아니라 최적화인데,
|
|
`source-state-plan.md`는 이걸 다이아몬드의 해결책으로 승격시켜 서술함.
|
|
**base 안에서 두 문서가 같은 문제의 해결 주체를 다르게 지목하고 있음.**
|
|
3. **⭐ 확정된 `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`.
|
|
|
|
### 이 문서에 미치는 영향 — 세 개가 사라짐
|
|
|
|
1. **"파생 State 위에서 퇴화한다"는 함정 자체가 없음.** 게이트는 중간에
|
|
무엇이 끼어 있든 매 변경마다 신호를 받으므로 `Debounce`가 어디서든
|
|
정상 동작함.
|
|
2. **"`Source`에 가깝게 걸어라"는 규칙 불필요.** `Blocker`의 "파이프라인
|
|
끝에 걸어라"와의 "정확한 거울상"이라는 서술도 같이 폐기 — 예쁜
|
|
대칭이었지만 틀린 전제 위에 있었음.
|
|
3. **열린 질문 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`")에
|
|
그대로 맞음:
|
|
|
|
```lua
|
|
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. 옵션 필드
|
|
|
|
```lua
|
|
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`로 받는" 이미 확정된 패턴)를 그대로 재사용:
|
|
|
|
```lua
|
|
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`/타이머 정리 로직)를 그대로 호출 — 새 로직이
|
|
아니라 노출 방식만 다름.
|
|
- **의미**: `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 언어의
|
|
> 일부가 아님** — 순수 `luau` CLI엔 `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 범용 유틸" 그룹**임 — 특정 핸들러가 아니라 아무나 쓰는 배관.
|
|
|
|
```lua
|
|
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`로 정할 때 Roblox `CollectionService`와 웹
|
|
`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` 판별이 공짜로 따라옴.
|
|
|
|
**확정 형태** (페이로드 자리를 미리 주는 것까지 사용자 동의):
|
|
|
|
```lua
|
|
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._observers`,
|
|
`slot._mountedInst`, `_fired`)와 일치.
|
|
- **`_native`에 뭘 담을지는 전적으로 백엔드 자유** — coroutine 하나일
|
|
수도, 취소 플래그를 담은 테이블일 수도 있음(아래 "취소를 제공하지 않는
|
|
엔진" 절). base는 이 필드를 **읽지도 쓰지도 않고** 그저 `setTimeout`이
|
|
준 값을 `clearTimeout`에 되돌려줄 뿐임.
|
|
|
|
백엔드 구현(quad-roblox):
|
|
|
|
```lua
|
|
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을 안 주면?"에 대한 답은 "그래도 구현 가능"** — 프로바이더가
|
|
함수를 래핑하고 유효 플래그를 밖에서 뒤집으면 됨:
|
|
|
|
```lua
|
|
-- 네이티브 취소가 없는 엔진의 프로바이더 구현 스케치
|
|
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. 의사코드
|
|
|
|
> **⚠️ [2026-08-24 무효화 배너, 6라운드 손 트레이싱 `H-33`] 아래 의사코드의
|
|
> 골격은 확정된 API로는 성립하지 않는다 — 다시 쓸 것.** 옛 주의문은
|
|
> *"내부 훅 이름이 아직 가칭"*이라고만 말했는데, 문제는 이름이 아니라 **모양**
|
|
> 이다. 아래는 탑레벨 `Gate(self)` 생성자를 부르고 반환 객체에
|
|
> `.onUpstreamSignal`/`._flush`/`._cancel`을 **사후 대입**하는데, 확정된 형태는
|
|
> 정반대다:
|
|
>
|
|
> - `base/gate-plan.md`가 **`state:Gate(setup)`**(`setup: (emit) -> onUpstreamEmit`)로
|
|
> 못박으면서 *"탑레벨 `Gate(...)` 생성자는 안 만든다"*고 명시했다 →
|
|
> `Gate`라는 호출 가능한 값이 없어 `attempt to call a nil value`.
|
|
> - 이름을 `self:Gate(...)`로 고쳐도 `setup`이 핸들러를 **반환**해야 하는
|
|
> 프로토콜과 안 맞는다.
|
|
> - 호출자는 State 하나만 받고 **노드 객체에 접근할 수 없으므로**
|
|
> `Handle:Set({ Flush = gate._flush, ... })`는 표현 자체가 불가능하다.
|
|
>
|
|
> **다시 쓸 방향은 정해져 있다**(사용자 확정 2026-08-24,
|
|
> `base/gate-plan.md`의 5번 항목이 소스): `Debounce`/`Throttle`은 **`emit`을
|
|
> 아예 안 쥔다.** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나)
|
|
> **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류는
|
|
> `blocker:Policy(emit)`이 돌려준 핸들에 위임한다:
|
|
>
|
|
> ```lua
|
|
> state:Gate(function(emit)
|
|
> local b = Blocker()
|
|
> local pass = b:Policy(emit)
|
|
> return function() -- 상류 emit 도착 (동기)
|
|
> -- 타이머/창 판단 후 b:On() / b:Off() / b:OffWithoutEmit()
|
|
> pass()
|
|
> end
|
|
> end)
|
|
> ```
|
|
>
|
|
> - `gate:passThrough()` → **`b:Off()`**(보류분 1회 방출), 그 뒤 다시 `b:On()`.
|
|
> - `Trailing = false` 경로 → **`b:OffWithoutEmit()`**.
|
|
> - **`pending`은 없앤다** — "보류된 게 있는가"는 Blocker의 `HasBlockedEmit`이
|
|
> 이미 들고 있다(중복 상태를 안 만든다). 이게 `H-32`를 구조적으로 없앤다.
|
|
> - `Flush`/`Cancel` 핸들은 `setup` 클로저 안에서 `b`를 캡처해 만든다 —
|
|
> 노드 객체 참조가 필요 없다(`Blocker`가 onunblock 핸들을 등록하는 방식과
|
|
> 같은 우회).
|
|
>
|
|
> 아래 코드는 **창/타이머 정책 자체의 참고용**으로만 읽을 것 —
|
|
> `openWindow`/`onWindowEnd`/`MaxTime`의 분기 구조는 그대로 유효하다.
|
|
|
|
**⭐ [2026-08-24 `H-32`] 같이 고쳐야 할 논리 결함 하나 — `Trailing = false`에서
|
|
`pending`이 영구히 참으로 남는다.** 아래 코드는 창이 열려 있는 동안 오는 신호를
|
|
`trailing`과 **무관하게** `pending = true`로 세우는데, `pending`을 `false`로
|
|
되돌리는 자리는 **전부** `if pending and trailing then` 안에 있다
|
|
(`onWindowEnd` / `MaxTime` 콜백 / `_flush` 셋 다). 그래서
|
|
`Debounce{Leading=true, Trailing=false, MaxTime=…}`(문서가 *"버스트 시작에 한
|
|
번만"*이라며 직접 드는 정상 사용례)에서 한 버스트에 신호가 둘 이상 오면:
|
|
|
|
- `MaxTime`이 매번 재무장되며 **영원히 아무 효과가 없고**,
|
|
- `opts.Handle`로 받은 `:Flush()`가 그 가드에 막혀 **영구 no-op**이 된다
|
|
(사용자가 명시적으로 커밋을 요청해도 반응이 없다). `:Cancel()`만이
|
|
`pending = false`를 무조건 하므로 유일한 탈출구다.
|
|
|
|
**해소는 위 재작성에 흡수된다** — `pending`을 없애고 Blocker의
|
|
`HasBlockedEmit`을 쓰면 "보류분이 있는가"와 "그걸 어떻게 풀 것인가"가 분리되어
|
|
이 결함이 구조적으로 성립하지 않는다(`Trailing = false`는 `OffWithoutEmit()`
|
|
으로 표현되고, 그건 보류분을 **버리면서** 상태도 같이 비운다). 아래 코드를
|
|
참고할 때 이 결함을 그대로 옮기지 말 것.
|
|
|
|
```lua
|
|
-- 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절)**: `GateNode`는 다른 평범한 State와
|
|
똑같이 **매 `onUpstreamEmit` 진입 시 자기 `invalid`를 즉시 세운다** —
|
|
`source-state-plan.md`의 "전파 모델 확정" 절이 정한 전파 규칙(**[2026-08-21]**
|
|
"항상"이 아니라 "같은 에포크의 두 번째만 접는다" — `base/state-epoch-plan.md`)이
|
|
게이트 자신에게도 그대로 적용됨. 정책이 미루는 건 **다운스트림 통지(전파)뿐**
|
|
이지 invalid 세팅이 아님 — 그래서 창이 열려 있는 동안 `:Get()`을 불러도 항상
|
|
최신값이 계산됨(캐시가 stale한 채로 안 남음).
|
|
|
|
**⚠️ [용어 정정, 2026-08-24 6라운드 `H-33`] 이 문단도 위 무효화 배너의 적용
|
|
대상이다.** 원래 `gate.onUpstreamSignal` / `gate:passThrough()`라는 **가칭
|
|
스캐폴딩 이름**으로 쓰여 있었는데 둘 다 확정 API가 아니다 — 확정된 훅 이름은
|
|
`setup(emit) -> onUpstreamEmit`이고(`base/gate-plan.md`), 재작성 후
|
|
`Debounce`/`Throttle`은 `emit`을 직접 쥐지 않으므로 **`gate`라는 단일 객체를
|
|
호출부가 손에 쥐는 모양 자체가 없다**(대신 자기 `Blocker`를 `On()`/`Off()`
|
|
한다). **이 문단이 말하는 의미론적 결론(즉시 invalidate, 전파만 지연 —
|
|
`Blocker`의 gated state와 동일)은 그대로 유효하고**, 바뀐 건 그걸 표현하는
|
|
이름뿐이다.
|
|
|
|
---
|
|
|
|
## 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}`** (RxJS `sampleTime`) — 상류 변경과 무관하게 주기적으로
|
|
최신값을 통과. 이건 상류 신호가 없어도 타이머가 도는 거라 **유일하게
|
|
진짜 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`.
|
|
이력만 남겨둠(각 항목이 왜 그렇게 됐는지는 가리키는 절이 소스, 여기서
|
|
반복 안 함):
|
|
|
|
1. ~~**이름**~~ **[2026-08-19 해소]** — `Debounce`/`Throttle` 유지,
|
|
Roblox 관용 "debounce"와의 충돌은 문서 경고로만 대응. (9-1절)
|
|
2. ~~**의미론**~~ **[2026-08-19 해소]** — **(A) emit-gate 채택**, (B)
|
|
value-hold는 laziness와 상충해 철회. (4절)
|
|
3. ~~**공개 생성자 2개 + 내부 구현 1개(`Reset` 한 비트)**~~
|
|
**[2026-08-19 해소]** — 2026-08-14 개정안 그대로 채택, 이견 없었음.
|
|
(5-3절, 7절)
|
|
4. ~~**제어 핸들**~~ **[2026-08-19 해소]** — 넣기로 확정. 초안의 S1(핸들
|
|
없음)/S2(`Blocker`와 같은 공유 외부 객체) 둘 다 아니고, 개별은
|
|
`Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak
|
|
레지스트리로 브로드캐스트)로 수렴. (5-4절)
|
|
5. ~~**패키지 경계**~~ **[2026-08-14 해소]** — 사용자가 quad-base +
|
|
엔진별 태스크 배선으로 확정. 주입 표면이 3개→5개로 늘어나는 비용은
|
|
수용됨. op 이름/시그니처도 사용자가 지정
|
|
(`setTimeout(func, delay)` / `clearTimeout(handle)`). (6절)
|
|
6. ~~**`Debounce`를 파생 State 위에 걸었을 때**~~ **[2026-08-14 소멸]** —
|
|
전제(무효화 dedup이 신호를 삼킴)가 틀린 것으로 드러나 질문 자체가
|
|
없어짐. emit은 항상 재전파되므로 게이트는 어디에 걸든 정상 동작함.
|
|
(3절)
|
|
7. ~~**`base/blocker-plan.md` 한 줄 명확화**~~ **[2026-08-19 소멸]** — Q2가
|
|
(A)로 확정되면서 이 항목이 걱정했던 모순(값-지연 의미론과 "`Get()`은
|
|
라이브 레퍼런스" 문구의 충돌)이 애초에 안 생김 — (A)는 `Blocker`와
|
|
완전히 같은 메커니즘이라 그 문구가 그대로 맞음. (4절)
|
|
8. ~~**`Time = 0`을 허용할지**~~ **[2026-08-19 해소]** — 허용, "defer될
|
|
수 있음"만 문서화, 금지/에러 안 함. (1절 인용문)
|
|
9. ~~**`Timeout` 핸들의 타입**~~ **[2026-08-14 완전 해소]** — 사용자 결정으로
|
|
**`type Timeout = { __type_timeout: true, _native: any }`**(마커는
|
|
런타임에도 실제로 넣고, 백엔드 페이로드 자리도 타입에 미리 선언).
|
|
에이전트의 `any` 권고는 철회됨 — `bindLifetime`이 `any`인 건 거기
|
|
진짜로 아무거나 오기 때문이고, `setTimeout`/`clearTimeout`은 자기가
|
|
만든 것만 주고받는 **닫힌 루프**라 성격이 정반대이며 오히려
|
|
`clearTimeout(1)`을 타입 에러로 잡아주는 이득이 있음. 비용 논거도
|
|
무력화 — `Relate` 대신 네이티브 핸들을 필드에 직접 넣으면 되고,
|
|
`task.delay`가 코루틴을 만드는 옆에서 작은 테이블 하나는 노이즈.
|
|
더 판단할 것 없음. (6절)
|
|
10. ~~**`base/source-state-plan.md`의 무효화 dedup 문장 정정**~~
|
|
**[2026-08-14 해소·반영 완료]** 사용자가 "emit은 항상 전파함,
|
|
`Blocker`나 emit 전파 지연요소만 이를 지연할 수 있음"으로 확정하고
|
|
전체 정정을 지시 — 같은 세션에 base/reference/research/ROADMAP/
|
|
스파이크/audit까지 전부 반영했고, 역전 기록은
|
|
`archive/invalidate-dedup-propagation-reversed.md`. 상세는 3절.
|
|
11. ~~**`Time`/`MaxTime`을 `State`로 받을 수 있는가**~~ **[2026-08-19
|
|
신설·같은 날 해소]** — 허용. 구독이 아니라 `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` M2) + 백엔드 주입 표면(`setTimeout`/
|
|
`clearTimeout`) + `Blocker`(gated state) + `Ref`. **[2026-08-22 정정]**
|
|
**[2026-08-24 재정리]** 각각 **M2**(State 코어 · 게이트 · `Blocker`) /
|
|
**M8**(`Ref`)에서 확정되는 것들이라 그 이후 언제든 얹을 수 있다. 2026-08-22엔
|
|
`Blocker.luau`가 디스패치 쪽으로 앞당겨져 있어서 "게이트/`Blocker`는 다른
|
|
마일스톤"이라고 적었으나, 2026-08-24 마일스톤 순서 교체로 되돌아왔다. 그
|
|
"게이트 노드를 공용으로 빼는" 작업도 같은 M2에서 State 코어와 함께 한다 —
|
|
사용자 결정 "게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에
|
|
의존하므로)는 그대로 지켜진다. 1절에서 봤듯 같은 노드를 공유하므로 따로
|
|
하면 같은 걸 두 번 설계하게 되는 것도 그대로다. 표면/이름은 `base/gate-plan.md`가 소스(이 문서의 1절이 그 일반화를 처음
|
|
권고한 자리로 거기 인용돼 있다). 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
|
|
때나 나중에 얹으면 된다.
|