Merge pull request #9 from qwreey-bot/main

Sync 08.19 13:36 KST - Dev docs
This commit is contained in:
qwreey 2026-08-19 13:37:05 +09:00 committed by GitHub
commit 2b48bd0bf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 667 additions and 234 deletions

File diff suppressed because one or more lines are too long

View file

@ -128,6 +128,32 @@ pre-implementation-qa-round3.md`가 원본) — 트레이싱 중 `attachSlot`이
절이 소스. 논의 원문(최초 분석·사용자 정정·확인 질문과 답변 전문)은
`qa-request/pre-implementation-qa-round3.md`.
## [해소됨, 2026-08-19] `PopOnly``Detach` 리네임 + 공개 표면 위치 확정
`:List` reconcile의 "파괴하지 말고 자리만 비우라" 반환 센티널은 2026-08-18
가칭 `PopOnly`로 도입되며 사용자가 "이름은 변경될 수 있음, 더 생각해봐야함"
이라 남긴 채 `question.md` 용어 정리 항목에 열려 있었음. 사용자가 후속
세션에서 이름 아이디어를 요청 → 여러 후보(`Bench`/`Stash`/`Hold`/`Detach`
등)를 검토한 뒤 `Detach`로 좁혔고, 이어서 "이걸 어디에 던져줘야 하나"(공개
표면 위치)까지 같이 확정됨. 원문은
`session/2026-08-19-02-detach-naming-and-placement.md`.
- **이름 — `Detach`.** 근거 둘: (1) 이미 있는 `Extract`(호출자가 직접
부르는 명령형 추출)와 동사가 겹치면 헷갈리는데, `Detach`는 "화면(부모
계층)에서만 떼어낼 뿐 관리 주체는 여전히 reconcile"이라는 뜻이라
`Extract`의 "소유권을 통째로 호출자에게 넘긴다"와 자연스럽게 구분됨.
(2) `nil`(파괴)과의 대비도 더 직접적으로 드러남.
- **공개 표면 위치 — 패키지 최상위 export.** `Slot`이 함수(팩토리)라
`Slot.Detach`처럼 붙이려면 callable-table+메타테이블이 새로 필요한데,
sentinel 상수 하나 때문에 그 구조를 들이는 건 "실제로 관측된 문제에만
구조를 쓴다" 원칙에 안 맞음(사용자 지적). 대신 `None`이 이미 쓰는 패턴을
그대로 따름 — `None`도 여러 곳(Slot 요소, Attribute, offsetSource)에서
쓰이지만 공개 표면은 패키지 최상위(`quad-base/src/init.luau` 재노출)이고
실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있음. `Detach`도 같은
패턴 — 정의는 Slot 관련 파일 옆에 두고 `init.luau`에서 최상위로 재노출.
- 지금 유효한 설계는 `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절이
소스.
---
# 확인/결정 필요 목록

View file

@ -25,8 +25,9 @@ State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것.
지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기
`invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md`
"전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된
게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`research/
debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸 못 박아
게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/
debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에
들어옴. 이걸 못 박아
두는 이유: 과거에 "이미 `invalid`면 전파를 멈춘다"는 서술이 base에
있었고, 그건 사실상 `Blocker`가 하는 일을 모든 State에 암묵적으로 심는
것이라 `Blocker`의 존재 의의를 반쯤 지워버렸음(역전 경위는

View file

@ -1,10 +1,18 @@
# Debounce / Throttle — 시간 기반 전파 게이트
**상태**: research — **[2026-08-14 신설]** 사용자 요청("`Blocker`와 유사하게
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 + 엔진별
@ -33,6 +41,24 @@ Debounce/Throttle를 만들어야 한다")으로 신설. 여기 적힌 건 **에
> **`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.*` 카탈로그 밖의 별도
설계 질문"으로 분리해뒀던 항목이 이 문서의 출발점(그 문서 "열린 질문 —
@ -320,42 +346,47 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정"
근거는 순회 비용 최적화였지만 지금은 실제 중복 *계산*이라서).
---
## 4. 의미론 — 지연되는 건 "전파"인가 "값"인가
## 4. 의미론 — 지연되는 건 "전파"인가 "값"인가**(A) emit-gate로 확정 (2026-08-19)**
두 갈래가 있고, 이게 이 문서에서 두 번째로 큰 결정.
두 갈래를 놓고 두 라운드 동안 (B)를 권장안으로 들고 있었으나, **[2026-08-19]
(A)로 확정하고 (B)는 철회**. 아래는 최종 결론과, 왜 (B)가 무너졌는지의 기록.
### (A) emit-gate — `Blocker`와 완전히 동일
### (A) emit-gate — `Blocker`와 완전히 동일 — **채택**
게이트는 자기 `invalid`를 즉시 세우되 아래로 전파만 미룸. 창이 열려 있는
동안 누가 `debounced:Get()`하면 **최신값**이 나옴.
동안 누가 `debounced:Get()`하면 **최신값**이 나옴 — `Blocker`의 gated
state와 글자 그대로 같은 동작.
### (B) value-hold — 값 자체가 지연됨 (권장)
### ~~(B) value-hold — 값 자체가 지연됨~~ — **철회됨, laziness와 상충**
게이트는 창이 닫혀 있는 동안 **자기를 invalid로 만들지 않음**. 즉 캐시된
직전 값을 계속 들고 있고, `debounced:Get()`은 **지연된 값**을 반환. 타이머가
커밋할 때 비로소 invalid + 전파.
게이트가 창이 닫혀 있는 동안 자기를 invalid로 만들지 않고 캐시된 직전
값을 들고 있다가, `debounced:Get()`이 **지연된 값**을 반환하게 하려던
안. 업계 선례(VueUse `useDebounce`/RxJS `debounceTime`)와의 일치, `Blocker`와의
역할 분리를 근거로 두 라운드 동안 권장안이었음.
**(B)를 권하는 이유**:
**왜 무너졌는가**: 이 계약("`:Get()`은 항상 창 열리기 전의 held value")을
지키려면 게이트가 **창이 열리는 시점에 자기 캐시가 확실히 valid함**을
보장해야 하는데, 실제로는 안 그런 경로가 있음 — 직전 커밋에서
`invalid = true`로 세팅된 채 아무도 `:Get()`을 안 부르고 있다가(다운스트림이
한동안 안 읽는 경우, 예: 언마운트됐다 재마운트) 그 상태에서 새 창이
열리면, "창 안에선 invalid 세팅 안 함"이라는 (B)의 규칙이 이미 세팅돼
있던 `invalid=true`를 못 되돌려서 `:Get()`이 곧바로 최신값을 계산해버림 —
"held value" 계약이 조용히 깨짐. 이걸 고치려면 창이 열리는 순간 게이트가
upstream을 강제로 pull해 캐시를 스냅샷 떠야 하는데, **그건 정확히
Throttle이 막으려던 그 비싼 연산을 게이트 자신이 매 창마다 강제로 돌리는
것**이라 laziness가 깨짐 — Throttle의 주 용례(랙 걸리는 연산 게이팅)에서
치명적.
1. **업계 의미론과 일치.** VueUse `useDebounce`는 반환된 ref의 *값 자체*가
늦게 따라오고, RxJS `debounceTime`도 값의 방출을 미룸. "debounce된
상태"를 다른 곳에서 읽었을 때 debounce 안 된 값이 나오면 놀람.
2. **`Blocker`와 역할이 깔끔히 갈림.** `Blocker`는 "지금 중간 상태니까
구독자에게 알리지 않을 뿐, 진실은 이미 새 값"이고, Debounce는 "이
노드의 값은 정의상 늦게 따라오는 값"임. 후자를 (A)로 만들면 두 도구가
같은 자리에서 미묘하게만 다른 애매한 물건이 됨.
3. **확정된 원칙과 충돌하지 않음.** `base/blocker-plan.md`가 인용하는
"`Get()`은 라이브 레퍼런스를 준다" 원칙은 `base/source-state-plan.md`에서
실제로는 **레퍼런스 의미론**(테이블을 복사본이 아니라 라이브 참조로
준다, 그래서 `Get()` 결과를 캐시해두고 `==` 비교하면 안 됨)에 대한
것이지 "항상 상류 최신값"이라는 뜻이 아님. 게이트 노드의 *자기* 값이
지연된 값이라면 `:Get()`은 여전히 그 노드의 라이브 값을 주는 것.
→ 다만 blocker-plan의 그 한 줄이 후자로 읽힐 여지가 있어, 확정되면
그 문장에 한 줄 명확화를 넣는 게 좋음(열린 질문 Q7).
부수로, (B)를 지지했던 업계 선례(VueUse/RxJS)도 재검토하면 그대로 옮겨올
근거가 약함 — 둘 다 push/eager 모델(Vue reactivity의 effect는 즉시 도는
push, RxJS는 애초에 eager push 스트림)이라 "값이 지연된다"가 공짜로
성립하는 세계고, quad처럼 **pull-lazy가 원칙**인 곳엔 그대로 안 맞는
선례였음.
**(B)의 캐비엇 하나**: 노드가 아직 한 번도 계산된 적 없으면(캐시 없음)
붙잡고 있을 "이전 값"이 없어서 첫 `:Get()`은 그냥 최신값을 계산함.
"지연은 두 번째 값부터"라는 뜻 — 자연스럽고 무해하지만 문서에 명시할 것.
**(A) 채택의 부수 효과 — Q7 소멸**: (A)는 `Blocker`의 gated state와
완전히 같은 메커니즘이라, `base/blocker-plan.md`가 이미 쓰고 있는
"`Get()`은 라이브 레퍼런스를 준다" 문구가 그대로 맞고 별도 명확화가
필요 없음(옛 Q7이 걱정했던 모순은 (B)를 택했을 때만 생기는 문제였음).
---
@ -383,24 +414,35 @@ local sampled = mousePos:Apply(Throttle{Time = 1 / 30})
```lua
type DebounceOptions = {
Time: number, -- 필수. 창 길이(초) — 신호마다 리셋됨
Leading: boolean?, -- 기본 false. 버스트의 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 조용해진 뒤 한 번 통과시킬지
MaxTime: number?, -- 기본 nil. 신호가 안 끊겨도 최대 이 간격마다 강제 통과
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, -- 필수. 창 길이(초) — 신호가 리셋하지 못함
Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지
Time: number | State<number>, -- 필수. 창 길이(초) — 신호가 리셋하지 못함
Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지
Handle: Ref<GateHandle>?, -- 기본 nil. 이 인스턴스 전용 제어 핸들(5-4절)
}
```
- **모든 필드는 plain 값만**`State`를 못 받음. `base/tween-plan.md`
"옵션 값 모양" 절이 `Tween{...}`에 대해 확정한 것과 같은 규칙, 같은
이유(옵션 안에 두 번째 반응 경로를 만들지 않음). `Time`을 반응형으로
바꾸고 싶으면 `state:Apply(...)` 자체를 다시 만드는 상위 구조가 필요한데,
그건 이 프리미티브가 아니라 `State<State<T>>` 영역.
- **[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`의
@ -432,7 +474,81 @@ type ThrottleOptions = {
끊이지 않으면 영원히 발화 안 함"이 디바운스의 정의라 그 안전장치가
필요하지만, 스로틀은 원래 주기적으로 발화하므로 무의미함.
→ 열린 질문 Q3(이 개정안 확인).
**[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`로 되돌리되 **전파는 안 함**(버림).
---
@ -662,10 +778,17 @@ function clearTimeout(timeout: Timeout) timeout._native() end
> **주의**: `Gate` 노드의 내부 훅(`onUpstreamSignal`, `commit`)은 아직
> 이름도 계약도 확정되지 않은 가칭 — `Blocker`의 게이티드 노드가 이미
> 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것.
> 아래 코드의 `registry`/`Handle` 배선도 마찬가지로 스케치 수준 — 실제
> 구현 시 이름은 자유.
```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면 아무것도 통과하지 않음")
@ -673,66 +796,103 @@ local function makeGate(reset: boolean, opts)
local leading = if reset then opts.Leading == true else opts.Leading ~= false
local trailing = opts.Trailing ~= false
-- 팩토리 — :Apply가 state마다 한 번씩 호출하므로 아래 지역 상태는
-- 게이트 노드 하나당 하나씩 새로 생김(팩토리 재사용해도 공유 안 됨)
return function(self)
local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드
local pending = false -- 창 안에서 상류 신호가 있었는가
local window = nil -- 살아있으면 "창 안", nil이면 idle
local cap = nil -- MaxTime 타이머(Debounce 전용)
-- 팩토리 레벨 상태 — makeGate 호출(=Debounce{...}/Throttle{...} 한 번)당 하나.
-- weak 레지스트리라 여기 등록돼도 게이트의 GC를 막지 않음(5-4절).
local instances = setmetatable({}, {__mode = "k"})
local openWindow, onWindowEnd
local function flushAll()
for gate in instances do gate._flush() end
end
local function cancelAll()
for gate in instances do gate._cancel() end
end
function openWindow()
window = setTimeout(onWindowEnd, opts.Time)
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 전용)
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
local openWindow, onWindowEnd
function openWindow()
window = setTimeout(onWindowEnd, readTime(opts.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)
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
-- 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, opts.MaxTime)
-- 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
end
return gate
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
@ -754,10 +914,14 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end
3.5 입력 → window == nil → leading 즉시 통과 ●
```
**(B) value-hold 의미론이 붙는 자리**: `gate``onUpstreamSignal`에서
자기 `invalid`를 세우지 않고, 오직 `passThrough()`에서만 세움 — 그래서
창이 열려 있는 동안 `gate:Get()`은 캐시된 직전 값을 반환. (A)를 택하면
`onUpstreamSignal` 진입 시 invalid만 세우고 전파를 미루는 형태로 바뀜.
**(A) emit-gate가 붙는 자리(확정, 4절)**: `gate`는 다른 평범한 State와
똑같이 **매 `onUpstreamSignal` 진입 시 자기 `invalid`를 즉시 세운다**
`source-state-plan.md`의 "전파 모델 확정" 절이 정한 "emit은 항상 전파된다"
규칙이 게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`
실제로 미루는 건
**다운스트림 통지(전파)뿐**이지 invalid 세팅이 아님 — 그래서 창이 열려
있는 동안 `gate:Get()`을 불러도 항상 최신값이 계산됨(캐시가 stale한
채로 안 남음).
---
@ -787,9 +951,9 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end
---
## 9. 이름
## 9. 이름`Debounce`/`Throttle` 확정 (2026-08-19)
### 9-1. ⚠️ Roblox 커뮤니티의 "debounce"와 충돌함
### 9-1. ⚠️ Roblox 커뮤니티의 "debounce"와 충돌함 — 유지로 확정
Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리언**을
가리킴(`local debounce = false ... if debounce then return end`). 웹
@ -808,9 +972,9 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
| `Coalesce` | "합친다"는 동작 자체 | nil 병합(`Alternative`)과 어휘 충돌 |
| `RateLimit` (Throttle 자리) | 의미 명확 | 서버 레이트 리밋 뉘앙스 |
**권고**: `Debounce`/`Throttle` 유지 + **사용자 문서 첫 줄에 Roblox 관용
"debounce"와 다르다는 걸 못박기**. 업계 표준 이름을 버리면 검색·이주
비용이 더 큼. 단 이건 사용자 취향이 강하게 걸리는 영역이라 Q1로 넘김.
**[2026-08-19 확정]** `Debounce`/`Throttle` 유지 + **사용자 문서 첫 줄에
Roblox 관용 "debounce"와 다르다는 걸 못박기**. 업계 표준 이름을 버리면
검색·이주 비용이 더 크다는 게 채택 근거 — 더 판단할 것 없음(구 Q1).
### 9-2. `-ed`를 안 붙이는 이유 (이건 코퍼스 규칙으로 결정됨)
@ -856,24 +1020,24 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
---
## 12. 사용자 판단 대기 — 열린 질문
## 12. 사용자 판단 대기 — 전부 해소됨 (2026-08-19)
1. **이름**`Debounce`/`Throttle` 유지(권장) vs 대안. Roblox 관용
"debounce"(재진입 불리언)와의 충돌을 문서 경고로만 다룰지. (9-1절)
2. **의미론** — (A) emit-gate(`Blocker`와 동일, `:Get()`은 최신값) vs
**(B) value-hold(권장, `:Get()`도 지연된 값)**. (4절)
3. **[2026-08-14 개정] 공개 생성자 2개 + 내부 구현 1개(`Reset` 한 비트)**
— 초안의 "`Throttle`은 `Debounce{MaxTime}` 프리셋"은 이중 발화 버그가
있어 폐기됨. 개정안 확인만 필요. (5-3절, 7절)
4. **제어 핸들(`:Flush()`/`:Cancel()`)을 v1에 넣을지.** 세 가지 모양:
- **S1(권장, v1)**: `state:Apply(Debounce{...})` — 핸들 없음.
`Operator` 관용구와 완전 일치, 가장 단순.
- **S2**: `local d = Debouncer{...}; state:Debounce(d)` + `d:Flush()`/
`d:Cancel()`**`Blocker`의 모양과 글자 그대로 같음**(외부 객체를
들고 `state:Block(blocker)`로 배선). 구조적 추가 비용이 사실상 없고,
"검색창에서 Enter 누르면 즉시 커밋" 같은 실사용 요구를 커버함.
- **S3**: `__call`을 가진 객체로 S1/S2를 겸함 — 매직이라 비권장.
실사용에서 Flush가 자주 필요하다고 보면 처음부터 S2가 나음.
**[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 이름/시그니처도 사용자가 지정
@ -882,14 +1046,12 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
전제(무효화 dedup이 신호를 삼킴)가 틀린 것으로 드러나 질문 자체가
없어짐. emit은 항상 재전파되므로 게이트는 어디에 걸든 정상 동작함.
(3절)
7. **`base/blocker-plan.md` 한 줄 명확화** — 그 문서가 인용하는
"`Get()`은 라이브 레퍼런스를 준다"는 실제로는 `base/source-state-plan.md`
**레퍼런스 의미론** 원칙이지 "항상 상류 최신값"이 아님. Q2에서 (B)를
택하면 두 문서가 모순돼 보이므로 그 인용에 한 줄 주석을 넣는 게 좋음.
(확정 문서 수정이라 사용자 승인 후 반영 — 이 세션에선 안 건드림.)
8. **`Time = 0`을 허용할지** — "이번 스텝 합치기" 용도로 유용하지만
스케줄러 타이밍에 의존하는 준-`Blocker`가 됨. 허용(권장) / 금지 /
별도 이름 부여 중 선택. (1절 인용문)
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 }`**(마커는
런타임에도 실제로 넣고, 백엔드 페이로드 자리도 타입에 미리 선언).
@ -906,18 +1068,41 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
전체 정정을 지시 — 같은 세션에 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. 우선순위 / 마일스톤
## 13. 우선순위 / 마일스톤**[2026-08-19 재평가] 결국 순수 슈가로 귀결**
**M0 착수를 막지 않음** — 이 문서의 어떤 결정도 디스패치/State 코어 계약을
바꾸지 않음(11절에서 확인). 다만 `Operator` 슈가와 달리 **순수 슈가가
아니라 실제 기능 갭**이라(타이머 없이는 사용자 코드로 재현하기 번거롭고,
재현하면 laziness를 깨기 쉬움) 우선순위는 그쪽보다 위로 두는 게 맞아 보임.
바꾸지 않음(11절에서 확인).
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면. 그 둘이
서면 언제든 얹을 수 있고, `Blocker` 구현과 **같은 시점에 하는 게 확실히
쌈** — 1절에서 봤듯 게이트 노드를 공유하므로 따로 하면 같은 걸 두 번
설계하게 됨. **권고: `Blocker` 구현 시점(M3)에 게이트 노드를 공용으로
빼두고, Debounce/Throttle 자체는 그 위에 나중에 얹기.**
**[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`. 전부 M3/M8 안에서
확정되는 것들이라 그 이후 언제든 얹을 수 있다. **`Blocker` 구현
시점(M3)에 게이트 노드를 공용으로 빼두는 것만은 여전히 그 시점에 해야
함** — 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번
설계하게 됨. 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
때나 나중에 얹으면 된다.

View file

@ -850,7 +850,7 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it
"지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면
**[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전
— "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible =
false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시
false`도 아니고, 언마운트도 아님 — `rawRemove`. `Detach`를 명시
반환해야 대신 언마운트+재사용됨). 편의상
`nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라
`:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw
@ -987,15 +987,15 @@ end
**⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale
서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로
쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil`
리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소,
리턴은 파괴가 기본 — `Detach`로만 비파괴" 절(SL-3 해소,
`question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로
`updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고,
"Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면
`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이
`nil` 대신 명시적으로 `Detach`를 반환해야 언마운트+재사용이 된다. 이
절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제
자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은
`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위
"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것.
`Detach`(당시 가칭 `PopOnly`) 신설로 대체됐다 — 이 절을 읽고 filter를
구현할 땐 반드시 위 "`nil` 리턴은 파괴가 기본" 절도 같이 볼 것.
**"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** —
item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그
@ -1097,10 +1097,10 @@ function activateList(self, inst)
local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능)
local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key])
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
-- [2026-08-18] PopOnly(가칭)는 "이 자리를 비우되 죽이지는 말라"는 지시.
-- 아래 "PopOnly" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다.
local popOnly = (result == PopOnly)
if popOnly then result = nil end
-- [2026-08-18, 이름 2026-08-19 확정] Detach는 "이 자리를 비우되 죽이지는 말라"는 지시.
-- 아래 "Detach" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다.
local detach = (result == Detach)
if detach then result = nil end
if result ~= nil then
-- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그
@ -1115,10 +1115,10 @@ function activateList(self, inst)
-- "`nil` 리턴은 파괴가 기본" 절이 소스:
-- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만**
-- — state<Frame> 교체와 동형, 지우라고 한 적이 없음.
-- (b) PopOnly: **언마운트만**, 재사용은 ud가 홀드.
-- (b) Detach: **언마운트만**, 재사용은 ud가 홀드.
-- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시.
if prev ~= nil then
if result ~= nil or popOnly then rawUnmount(self, prev)
if result ~= nil or detach then rawUnmount(self, prev)
else rawRemove(self, prev) end
end
if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준
@ -1128,7 +1128,7 @@ function activateList(self, inst)
end
userdata[key] = ud -- result와 무관, 그대로 기록
-- (PopOnly 재사용은 여기 담긴 { old = ... }가 담당)
-- (Detach 재사용은 여기 담긴 { old = ... }가 담당)
newKeyIndex[key] = pos
end
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
@ -1211,7 +1211,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
`rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째
세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을
**부분적으로 되돌림**`nil` 리턴/키 소멸은 다시 **파괴**가 기본이고,
값 교체와 `PopOnly`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이
값 교체와 `Detach`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이
소스) — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는
가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘
자체가 그 셋을 직접 호출할 일이 없을 뿐. `rawUnmount``rawRemove`
@ -1228,7 +1228,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로
미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정.
### `nil` 리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기)
### `nil` 리턴은 파괴가 기본 — `Detach`로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기; 이름 2026-08-19 확정)
**[재정정]** 2026-08-13 여섯 번째 세션은 "자동 경로는 언마운트, 명시적으로
지우라고 한 것만 파괴"라는 일반 규칙을 세우면서 `:List`의 reconcile까지
@ -1242,17 +1242,17 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
|---|---|---|
| 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state<Frame>` 교체와 동형이고, `Slot { State<Slot> }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State<Slot>` 교체" 절의 확정도 그대로 유지됨 |
| `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
| `PopOnly`(가칭) | **언마운트만** + 재사용 대기 | 아래 |
| `Detach` | **언마운트만** + 재사용 대기 | 아래 |
| 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) |
**`PopOnly`(가칭)`Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.**
**`Detach``Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.**
`filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번
파괴/재생성하는 건 비싸다. 그래서 `updateFn`**`PopOnly`와 함께 userdata를
파괴/재생성하는 건 비싸다. 그래서 `updateFn`**`Detach`와 함께 userdata를
반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다:
```lua
-- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다
return PopOnly, { old = prev, source = ... }
return Detach, { old = prev, source = ... }
```
- **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐
@ -1264,7 +1264,7 @@ return PopOnly, { old = prev, source = ... }
전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다.
- **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서
발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를
**둘 다** 지우는데, `PopOnly`로 홀드 중이던 요소는 `mounted[key]`가 이미
**둘 다** 지우는데, `Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미
`nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는
**파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이
된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고
@ -1274,12 +1274,33 @@ return PopOnly, { old = prev, source = ... }
(b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침),
(c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다.
**지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지.
- **이름은 가칭** — 사용자 확정: *"PopOnly 확정. 다만 이름은 변경될 수
있음. 이름에 대해서는 더 생각해보아야함"*. `question.md` 용어 정리 항목에
올려둠. 메커니즘(반환 규약 + userdata 홀드 + 재마운트)은 확정.
- **이름 확정 — `Detach`(2026-08-19).** 처음엔 가칭 `PopOnly`로 도입됐고
사용자가 *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더
생각해보아야함"*이라고 남겨 `question.md` 용어 정리 항목에 올라가
있었음 — 이후 세션에서 후보들을 검토하다 `Detach`로 확정. 근거는 둘:
(1) 이미 있는 `Extract`(바로 아래 불릿, 호출자가 직접 부르는 명령형
추출)와 동사가 겹치면 헷갈리는데, `Detach`는 "화면(부모 계층)에서만
떼어낼 뿐 관리 주체는 여전히 reconcile"이라는 의미라 `Extract`
"소유권을 통째로 호출자에게 넘긴다"는 것과 자연스럽게 구분됨. (2)
`nil`(파괴)과의 대비도 더 직접적으로 드러남. 메커니즘(반환 규약 +
userdata 홀드 + 재마운트)은 그대로 — 원문은
`session/2026-08-19-02-detach-naming-and-placement.md`.
- **공개 표면 위치 확정 — 패키지 최상위 export(2026-08-19).** `Slot`
함수(팩토리)라 `Slot.Detach`처럼 붙이려면 callable-table+메타테이블이
새로 필요한데, sentinel 상수 하나 때문에 그 구조를 들이는 건 과함
(`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고
구조를 복잡하게 만들지 않는다" 원칙). 대신 `None`과 같은 선례를 따른다 —
`None`도 여러 곳(Slot 요소, Attribute, offsetSource)에서 쓰이는
sentinel이지만 공개 표면은 패키지 최상위(`quad-base/src/init.luau`
재노출)이고 실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있다.
`Detach`도 같은 패턴 — **정의는 Slot 관련 파일(`Slot.luau` 또는
`Dispatch/Slot.luau`) 옆에 두고, `init.luau`에서 최상위로 재노출**한다.
지금은 `:List` reconcile 한 곳에서만 쓰이지만 `None`도 처음엔 그렇게
시작해 이후 재사용됐으므로 최상위에 두는 게 자연스럽다. 정확한 파일
배치는 M6 구현 시점에 확정.
- **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가
이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가
직접 부르는 명령형 경로**다. `PopOnly`는 같은 일을 **reconcile 안에서
직접 부르는 명령형 경로**다. `Detach`는 같은 일을 **reconcile 안에서
선언적으로** 하기 위한 것이라 서로 대체 관계가 아니다.
### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime`
@ -2114,7 +2135,7 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` +
`unbindLifetime` + `releaseOwner`를 부름.
2. **`:List``reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서
비파괴가 되는 건 **값 교체와 `PopOnly`뿐**이다. `updateFn``nil`/`None`을
비파괴가 되는 건 **값 교체와 `Detach`뿐**이다. `updateFn``nil`/`None`을
반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자
판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스.
2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은
@ -2125,7 +2146,7 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
`:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로
지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가
"지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로"
철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `PopOnly`로 그 의도를
철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `Detach`로 그 의도를
명시한다.
`unmountSlotTree``destroySlotTree`가 하는 일 중 **실제 파괴와 자식

View file

@ -194,9 +194,10 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가
재계산하고, 그 결과를 캐시에 넣고, `invalid`를 끈다.
- **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐**
지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고, 앞으로 추가된다면
`research/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에
들어옴. **평범한 State는 절대 신호를 삼키지 않는다.**
지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고,
`base/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에
들어감(설계 확정, 구현은 아직). **평범한 State는 절대 신호를 삼키지
않는다.**
- **[2026-08-14 정정 — 중요]** 이 자리엔 원래 "이미 `invalid`였다면 그
아래로 더 전파하지 않는다(다이아몬드 중복 워크 방지)"라고 적혀 있었으나
**틀린 서술이라 뒤집힘**. 그대로 두면 `:Get()`을 호출하지 않는

View file

@ -48,11 +48,16 @@
"문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로
보완하기로 같이 확정. 코퍼스 반영 완료 —
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절.
- **`PopOnly`(가칭, 2026-08-18 신설)**: `:List` reconcile에서 "파괴하지 말고
자리만 비우라"를 지시하는 반환 센티널(`base/slot-plan.md`의 "`nil` 리턴은
파괴가 기본" 절). 메커니즘은 확정됐고 **이름만 열려 있음** — 사용자:
*"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더
생각해보아야함"*.
- **[해소됨, 2026-08-19] `PopOnly``Detach` 확정** — 원문과 근거는
`archive/question-resolved.md`. 요지: 이미 있는
`Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데,
`Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히
reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게
구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도
같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면
callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라
**패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`
"`nil` 리턴은 파괴가 기본" 절.
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
헷갈릴 수 있음.
@ -134,35 +139,13 @@
몫. 같은 리서치에서 포함 범위도 새로 갈렸음 — 비트/비교 연산자 그룹과
`Sub`/`Div`는 리액티브 콤비네이터로서 선례가 전혀 없어 드랍 후보로,
`Clamp`/`Min`/`Max`는 선례가 강해 추가 후보로, Debounce/Throttle은
업계에 흔하지만 `Blocker`와는 다른 시간 기반 메커니즘이라 이 카탈로그가
아니라 quad-roblox 쪽 별도 프리미티브로 다룰지 판단이 필요한 별개 질문으로
분리됨. **[2026-08-13 세션 신설]** `Alternative`(nil 대체값, coalesce/`??`/
업계에 흔하지만 `Blocker`와는 다른 시간 기반 메커니즘이라 이 카탈로그
밖 별개 질문으로 분리됐었음 — **[2026-08-19 해소]** 그 별개 질문은
`base/debounce-throttle-plan.md`로 전부 해소·승격 완료, 더 이상 판단
대기 아님. **[2026-08-13 세션 신설]** `Alternative`(nil 대체값, coalesce/`??`/
엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정
규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`.
구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함.
- **[신설, 2026-08-14] Debounce/Throttle — 남은 열린 질문(개수는
`research/debounce-throttle-plan.md` 12절이 소스, 여기서 반복 안 함)** —
사용자 요청("`Blocker`와 유사하게 만들어야 한다")으로
`research/debounce-throttle-plan.md` 신설. 네 번의 리뷰 라운드로
**패키지 경계·주입 op 시그니처·`Timeout` 타입·동작 정식화는 전부
확정**됐고, 사용자 취향/방향이 갈리는 것만 남음 — 주요 4개:
- **의미론** — 창이 열려 있는 동안 `:Get()`이 최신값을 주는가
(`Blocker`와 동일) vs 지연된 값을 주는가(권장, VueUse/RxJS와 일치).
- **제어 핸들**`state:Apply(Debounce{...})`로 끝낼지, `Blocker`
글자 그대로 같은 모양(`Debouncer` 객체 + `state:Debounce(d)` +
`d:Flush()`/`:Cancel()`)으로 갈지. "검색창에서 Enter 누르면 즉시
커밋"류가 잦다고 보면 후자가 처음부터 나음.
- **이름** — Roblox 관용 "debounce"(재진입 방지 불리언)와 정면 충돌.
업계 표준 이름을 유지하고 문서로 경고할지, 다른 이름을 쓸지.
- **`Time = 0` 허용 여부** — "이번 스텝 합치기"로 유용하지만 스케줄러
타이밍에 의존하는 준-`Blocker`가 됨.
이 외에 사소한 것 둘도 12절에 열려있음: 공개 생성자 2개 개정안이
사용자 확인만 남은 상태, `base/blocker-plan.md`가 인용하는 "`Get()`은
라이브 레퍼런스" 문구에 한 줄 명확화가 필요(확정 문서 수정이라 승인
대기, 아직 안 건드림). 우선순위 낮음 — M0를 막지 않고 코어 계약도 안
건드림. 다만 **M3에서
`Blocker`를 구현할 때 게이티드 노드를 공용으로 빼두는 것**만은 그
시점에 해야 함(따로 하면 같은 설계를 두 번 함).
- **중첩 State 평탄화 `State<State<T>>``State<T>`(2026-08-13 여섯 번째
세션 신설, 백로그)** — **[근거 축소, 열네 번째 세션]** 원래 이 항목의
주 근거는 "깊은 체인에선 힌트가 `nil`로 전달돼 깜빡임 방지가 꺼진다"는
@ -181,7 +164,7 @@
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `PopOnly`로 홀드 중이던
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던
요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면
`mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가
`userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고

View file

@ -91,7 +91,8 @@ State 메소드로 두려던 초기 폼팩터가 기각된 경위만 여전히
세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황.~~
**[2026-08-14 뒤집힘]** 사용자가 직접 "`Blocker`와 유사하게 만들어야
한다"고 지정 — 위 "빈 자리 아님" 판정은 더 이상 유효하지 않음.
설계 초안은 `research/debounce-throttle-plan.md`. (원 판정의 근거였던
설계는 `base/debounce-throttle-plan.md`(2026-08-19 전부 해소돼
`research/`에서 승격). (원 판정의 근거였던
"세 레포에 없다"는 관찰 자체는 사실이지만, 2026-08-12 열아홉 번째 세션의
외부 리서치에서 RxJS/VueUse 등 Roblox 밖에선 가장 흔한 콤비네이터
카테고리 중 하나로 확인돼 근거로서의 무게가 이미 약해져 있었음.)

View file

@ -237,8 +237,9 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어
모양을 벗어남 — `Operator.*`(quad-base, 엔진 무종속)에 넣을 수 있는 게
아니라 quad-roblox 쪽 별도 프리미티브(Tween과 비슷한 위치)로 다뤄야 할
가능성이 큼. 이 문서 범위 밖의 별도 설계 질문으로 분리해서 판단 필요.
**[2026-08-14, 분리 완료]** 사용자 요청으로 `research/debounce-throttle-plan.md`
신설 — 이 항목은 그 문서로 이관됐고 여기선 더 이상 다루지 않음. 그
**[2026-08-14, 분리 완료, 2026-08-19 설계 전부 해소돼 `base/debounce-throttle-plan.md`
승격]** 사용자 요청으로 신설 — 이 항목은 그 문서로 이관됐고 여기선 더
이상 다루지 않음. 그
문서의 결론 두 가지만 여기 관련 있음: (1) 붙이는 모양은 이 카탈로그와
똑같이 `factory(self)` + `:Apply`라 위 "왜 `:Apply`인가" 규칙이 그대로
적용됨, (2) 다만 배치는 위 추측과 달리 **quad-base + 주입 op 2개**로

View file

@ -1504,3 +1504,34 @@ key로 완료 여부 기록) 방식으로 직접 해소하는 아이디어도
포함)가 있었는데 그날 session/ 파일은 1개뿐, 2026-08-19는 이 세션 전까지
(QA 3라운드 커밋 1개가 있었음에도) 0개였음. 과거 대화 트랜스크립트에 접근 불가라 그 공백을 사후 재구성하는 건
허위 기록 위험이 있어 보류 — 처리 방침은 사용자 확인 대기.
## 2026-08-19 — `PopOnly``Detach` 리네임 + 공개 표면 위치 확정
원문: `session/2026-08-19-02-detach-naming-and-placement.md`
가칭으로 남아있던 `:List` reconcile의 비파괴 반환 sentinel `PopOnly`
이름을 사용자 요청으로 브레인스토밍(풀링/보관 은유 계열, "언마운트+보류"
계열, 기존 조어 구조 계열 후보 제시) → 사용자가 이미 있는 `Extract`(명령형
추출)와 동사가 안 겹치면서 "화면에서만 떼고 관리 주체는 그대로"라는 뜻을
살릴 수 있다는 이유로 `Detach`를 골라 확정. 이어서 공개 표면 위치("Slot이
함수라 `Slot.Detach`로 못 붙임")도 논의 — `None` sentinel의 선례(공개
표면은 패키지 최상위 export, 실제 정의는 관련 로직 옆)를 그대로 따르기로
확정. `base/slot-plan.md`/`question.md`/`todos.md`/
`archive/question-resolved.md`/`ROADMAP.md` 전량 반영, `doc-check.py`
ERROR 0.
## 2026-08-19 — `Debounce`/`Throttle` 마지막 판단 대기 4개 닫음, `base/`로 승격
원문: `session/2026-08-19-03-debounce-throttle-final-close.md`
`research/debounce-throttle-plan.md` 12절에 남아있던 이름/의미론/제어
핸들/`Time=0`을 리뷰 — 이름은 유지+문서 경고로 사용자가 먼저 확정, 나머지
셋은 논의를 거쳐 (A) emit-gate 채택(값-지연 (B)는 laziness와 상충해
철회), 제어 핸들은 개별 `Ref` 아웃파라미터+전체 팩토리 브로드캐스트(weak
레지스트리)로 수렴, `Time=0`은 허용으로 확정. 부수로 `Time`/`MaxTime`을
`number|State<number>`로 확장(스케줄 시점에만 폴링, 구독 아님)도 결정.
전부 닫히면서 문서가 `research/`에서 `base/`로 승격됐고, 제어 핸들 설계가
`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힌다는 게
드러나 **순수 슈가로 재평가**(옛 "실제 기능 갭이라 우선순위 위" 서술
철회). `ROADMAP.md`/`question.md`/`todos.md`/`README.md`/
`source-state-plan.md`/`blocker-plan.md` 전량 반영.

View file

@ -0,0 +1,72 @@
# 2026-08-19 — `PopOnly``Detach` 리네임 + 공개 표면 위치 확정
**요청**: `base/slot-plan.md`에 가칭으로 남아있던 `PopOnly`(`:List`
reconcile에서 "파괴하지 말고 자리만 비우라"는 반환 센티널)의 이름을 사용자가
직접 못 정해 아이디어를 요청 — "지금은 다른 에이전트들이 있어서 쓰지는
말아달라"는 조건으로 브레인스토밍만 먼저 진행.
## 1. 메커니즘 재확인
`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절을 다시 읽고 의미를
정리: `:List` reconcile에서 `updateFn`이 반환하는 sentinel로, 이 자리를
언마운트(`Parent = nil`)는 하되 **파괴는 하지 말고 userdata를 들고 있다가
나중에 재사용**하라는 지시 — 목적은 `Instance.new`/`Destroy` 비용을 아끼는
재사용(오브젝트 풀링에 가까움). `nil` 반환(파괴)과도, 새 값 반환(언마운트만,
즉시 교체)과도 구분되는 세 번째 경로.
## 2. 이름 후보 제시
풀링/보관 은유 계열(`Bench`/`Stash`/`Shelve`/`Park`), "언마운트+보류" 계열
(`Hold`/`Detach`/`Suspend`), 기존 조어 구조를 살리는 계열(`PopKeep`/
`KeepOnly`/`SoftPop`)을 제시하고 `Bench`(가장 직관적인 은유) 또는 `Hold`
(문서가 이미 "홀드"라는 용어를 쓰고 있어 도입 마찰이 적음) 둘 중 하나를
추천.
## 3. 사용자가 `Detach` 선택 — 근거 검증
사용자: *"Detach 많이 괜찮아보이는데, Slot 에서 뽑아내다라는 Extract 를
지금 쓰고 있으니까, 이미 있는 Extract 를 쓰긴 뭐하니 Detach 를 넣는건
어때보임?"*
`Extract`/`ExtractAll`은 호출자가 직접 부르는 **명령형** 추출(소유권을
통째로 호출자에게 넘김)인 반면, `Detach`(구 `PopOnly`)는 reconcile 내부에서
여전히 userdata로 추적하며 화면(부모 계층)에서만 떼는 **선언적** sentinel —
"소유권 이관" vs "내부 보류 상태"로 성격이 갈려 `Extract`와 이름이 겹쳐도
혼동 소지가 적다고 판단, 채택 추천. Roblox 개발자가 `Attachment`/
`WeldConstraint`류의 물리적 "detach"를 먼저 연상할 여지는 있지만 "부모
계층에서 떼어낸다"는 실제 동작과 크게 어긋나지 않아 큰 문제 아니라고 정리.
`Detach` 채택 확정.
## 4. 공개 표면 위치 — 최상위 export
사용자: *"근데 그러면, Detach 는 어디에서 던져줘야한다 생각함? 그냥 base
모듈 바로 최상위? Slot 이랑 연관인데 Slot 옆에 존재하게 되는게 뭔가 느낌이
애매한거 같긴 함. 그런데 Slot 은 또 함수라서, 뭔가 넣기 애매해. 넣으려면
메타테이블이 되어야하기도 하고. 그냥 최상위가 좋음?"*
`architecture.md`의 패키지 트리를 확인해 `None` sentinel의 선례를 찾음 —
`None`도 Slot 요소/Attribute/offsetSource 등 여러 곳에서 쓰이는 sentinel
이지만, 공개 표면은 패키지 최상위(`quad-base/src/init.luau` 재노출)이고
실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있음(공개 표면과 구현
위치가 분리돼 있는 기존 패턴). `Detach`도 같은 성격의 sentinel이므로 같은
자리(최상위)에 두는 게 일관적이라고 판단 — `Slot`이 함수라 `Slot.Detach`
형태로 붙이려면 callable-table+메타테이블이 새로 필요한데, sentinel 상수
하나 때문에 그 구조를 들이는 건 `conventions.md`의 "드문 오용이나 가상의
미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙에
안 맞음. 정의 파일 위치(`Slot.luau` 또는 `Dispatch/Slot.luau`)는 `None`
같은 패턴(정의는 관련 로직 옆, 재노출만 `init.luau`에서)을 따르되 정확한
배치는 M6 구현 시점에 확정하기로 함.
## 5. 반영
사용자: *"이거 base/slot-plan.md에 정리해서 반영해줘. 세션 기록도
남겨주고, 핸드오버 준비해줘. 적절히 정해진것 같아서 커밋해줘도 될듯."*
`base/slot-plan.md`("이름은 가칭" 불릿을 "이름 확정"/"공개 표면 위치 확정"
두 불릿으로 교체, 본문 전체의 `PopOnly` 표기를 `Detach`로 치환하되 사용자
발언 직접 인용과 히스토리 서술은 유지), `question.md`(용어 정리 1번 항목을
`DI`→`D`와 같은 형식의 `[해소됨]` 항목으로 교체, 3번의 남은 열린 항목 이름만
치환), `todos.md`(00번/2번 항목 갱신), `archive/question-resolved.md`(새
`[해소됨, 2026-08-19]` 절 추가), `ROADMAP.md`(M6 체크리스트의 `PopOnly`
표기 치환)에 반영. 인덱스 레이어(`README.md`)와 `session-summary.md`
같이 갱신, 이 문서가 그 포인터.

View file

@ -0,0 +1,100 @@
# 2026-08-19 — `Debounce`/`Throttle` 마지막 판단 대기 4개 닫음, `base/`로 승격
**요청**: `research/debounce-throttle-plan.md` 12절에 남아있던 판단 대기
항목(이름/의미론/제어 핸들/`Time = 0`) 중 사용자가 이미 답을 준 이름(Q1)을
빼고 나머지를 리뷰. "지금은 다른 에이전트들이 있어서 읽기만 해달라"는
조건으로 시작 — 다른 에이전트들이 끝난 뒤 반영·핸드오버까지 진행.
## 1. 리뷰 준비 — 문서 재확인, 새 질문 발견
문서를 다시 읽는 과정에서 12절에 없던 새 항목이 나옴 — **`Time`/`MaxTime`을
`State`로 받을 수 있는가**(5-2절은 당시 "plain 값만"으로 확정돼 있었음).
사용자가 먼저 판단을 제시: *"1. 이름 -> Debounce Throttle 유지를 하는게
나도 맞다고 봄. 관용 표현과 다르다는 경고로 넣기로 문서화 계획만 되도록
해줘 + debounce-throttle 에 시간은 state 로 받을 수 있을지는 확인 필요한
부분으로 보임."*
## 2. Time as State
사용자: *"Throttle 과 Debounce 는 state 중간이나 말단에 들어가서 emit 을
제어하는거라, tween 과 완전 다름. 그리고 한번 바인딩 된다면 다시 Time
같은걸 바꿀 수 없게되어버림. (…) 필요 시마다 get 해서 쓰긴 하는거야."*
즉 구독이 아니라 **`setTimeout` 호출 시점에만 폴링**하자는 제안 — 검토
결과 5-2절이 인용하던 `tween-plan.md`의 "옵션에 두 번째 반응 경로를
안 만든다" 근거와 안 부딪힘(폴링은 새 무효화 채널이 아님). 후속 확인:
*"이미 스캐쥴 한건 반영 안함. Animate 슈거랑 비슷함. 오직 setTimeout
수행할 때 읽음. MaxTime 같은 다른것도 number|state<number> 주는거
가능해보임."* → `Time`/`MaxTime` 둘 다 `number | State<number>` 허용,
이미 스케줄된 타이머엔 미반영·다음 창부터 적용으로 확정.
## 3. Q2 — 의미론, (B) value-hold 철회
사용자: *"Get 을 지연된 값으로 만들어낸다 하면, 이전에 Compute 안 했던걸
다시 컴퓨팅 하기 어려움. 리프 바인딩이라 가정하면 그냥 Blocker 동작이랑
동일하는게 맞다고 생각하는데. (…) value-hold 라는걸 해야하는 순간부터,
Get을 명시적으로 호출하고 들고 있어준다는게 되지 않느냐는것. (…) 이전에
Get하지 않았다면 아주 이전 값일 가능성이 있는데. 이는 어떻게 제어하는가?"*
검토 결과 지적이 정확함을 확인 — (B)의 "held value" 계약은 직전 커밋에서
`invalid = true`로 세팅된 채 아무도 안 읽다가 새 창이 열리는 경로에서
조용히 깨지고, 이걸 고치려면 창이 열리는 순간 upstream을 강제로 pull해야
해서 laziness와 정면 충돌(Throttle의 주 용례인 "비싼 연산 게이팅"에서
치명적). **(A) emit-gate로 확정, (B) 철회.** 부수로 Q7(blocker-plan.md
명확화)도 (A)를 택하면서 자동 소멸.
## 4. Q4 — 제어 핸들, 세 라운드 만에 수렴
1차 제안(에이전트): 게이트가 반환하는 State 자체에 `:Flush()`/`:Cancel()`을
직접 붙임. 사용자 반박: *"State 가 확장되거나 해야함. 상태 따라 debounce
에 대한 메서드를 실행할 수 있거나 없고. 타입이 나뉘거나 함. 차라리
Debounce() 할 때, 디바운싱을 진짜 하는건 state 따로이지만, Flush Cancel
은 한 핸들에서 수행 가능하고(마치 On/Off 처럼) 그게 전파 되는건 어떻게
봄?"* — State 서브타입 분기 문제를 정확히 짚음.
2차 제안: `base/ref-plan.md``Ref`(채워지길 기다리는 빈 박스) 패턴을
재사용 — 옵션 필드로 `Handle = Ref()`를 받아 게이트 생성 시 채움. 사용자
동의, 이어서 대칭 확장 질문: *"Debounce{} 결과에 :Flush 하면 있는 전체가
플러싱되고, 하나만 하고 싶으면 Handler 를 넣어 쓰게 하면 되고,
정확해보임."* → 개별은 `Ref` 아웃파라미터, 전체는 팩토리 자신의
`:Flush()`/`:Cancel()`(모든 인스턴스에 브로드캐스트)로 최종 확정.
`:Apply()`를 계속 쓰는 이유도 확인: *"왜 Apply 일 이유가 있어? (…) 내부적으로
Blocker 로 동작하기 때문임?"* — 두 근거 다 유효(① `Operator` 관용구가
이미 "factory(self) + `:Apply`"로 확정, ② 내부적으로 `Blocker`와 같은
gated state를 공유) 확인 후 동의.
전체 브로드캐스트의 구현 함정(weak 레지스트리 필요, GC 방해 방지) 지적에
사용자 동의: *"1. 은 동의, weak 로 전부 가지고 있으면 돼."* `Cancel`
`Flush`와 대칭 포함 확정: *"Cancel도 대칭으로 포함해줘."*
## 5. Q8 — `Time = 0`
사용자: *"Q8 은 금지할 이유가 없어보이긴 함. 그냥 defer 될 수 있다고만
알리면 별 문제 없음."* → 허용, "defer될 수 있음"만 문서화, 별도 이름/에러
없음.
## 6. 부수 발견 — 순수 슈가로 귀결
사용자: *"이건 어쩌다 보니 사실상 슈거가 되었고, setTimeout 만 나중에
진짜 quad-base 에 추가적으로 들어가는 부분이 될듯. 표면 상 아주 나중에
구현되어도 괜찮아 보이는데, 지금 구현 상 base 승격에 문제 없어보임."*
제어 핸들까지 확정되면서 새로 필요한 quad-base 코어 표면이 주입 op
2개(`setTimeout`/`clearTimeout`)뿐임이 드러남 — 게이트는 `Blocker`가 이미
확정한 gated state 위, 핸들은 이미 확정된 `Ref` 위에 전부 얹힘. 옛
13절의 "순수 슈가가 아니라 실제 기능 갭이라 우선순위를 위로 둔다"는
서술을 뒤집고, `Operator.*`와 같은 급의 후순위로 재평가.
## 7. 반영
*"다른 에이전트들 다 끝났어, 이제 반영해줘. 세션 기록도 남기고 핸드오버
준비해줘. 끝나면 커밋하면 돼."*
`base/debounce-throttle-plan.md`(구 `research/debounce-throttle-plan.md`,
`research/`에서 승격 — 4차 리뷰 배너, 4/5-2/5-3/5-4/7/9-1/12/13절 갱신,
`DebounceHandle`/`Timeout` 등 타입 확장, 의사코드에 `readTime`/weak
레지스트리/`Flush`·`Cancel` 반영), `ROADMAP.md`(백로그 항목 경로+서술
갱신), `.claude/todos.md`(4번 항목), `.claude/question.md`(3번 항목
전체 제거 — 전량 해소), `.claude/README.md`(research→base 테이블 이동),
`base/source-state-plan.md`/`base/blocker-plan.md`(경로 참조 갱신)에
반영. `archive/`·`session/`의 과거 경로 참조는 히스토리 문서라 그대로
둠(`doc-check.py`가 파일명 기준 폴백 매칭이라 깨지지 않음).

View file

@ -59,12 +59,15 @@
시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도.
- **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md`
`:Unsubscribe()` 절) — M3 착수 전 확인.
- **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림.
- **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) —
지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정,
2026-08-18 `/code-review high``ROADMAP.md`의 M6 PopOnly 체크박스와
대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`)
아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음.
- **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면
위치도 `None`과 같은 최상위 export로 같이 확정). 원문은
`archive/question-resolved.md`.
- **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분**
(`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고
참조만 끊김. **[정정, 2026-08-18 `/code-review high``ROADMAP.md`
M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수
전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥
지나칠 위험이 있었음.
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
@ -142,7 +145,8 @@
2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
— 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영
완료로 목록에서 빠짐**, 대신 `PopOnly`(가칭)가 새로 들어옴): `Slot`(2순위),
완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영
완료로 목록에서 빠짐**): `Slot`(2순위),
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
@ -174,15 +178,17 @@
(`debug-tooling-plan.md`/`documentation-plan.md`/
`documentation-content-map.md`/`framework-comparison-findings.md`/
`operator-sugar-plan.md`).
**[2026-08-14 추가, 성격이 다름]** 시간 기반 전파 게이트
`Debounce`/`Throttle`(`research/debounce-throttle-plan.md`)도 백로그이긴
하나 위 항목들과 달리 **사용자가 직접 요청한 실제 기능 갭**이고 순수
슈가가 아님 — M0/M3를 막지는 않지만, **M3에서 `Blocker`를 구현할 때
게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 해야 함**(따로
하면 같은 설계를 두 번 함). 주입 op 2개(`setTimeout`/`clearTimeout`)가
백엔드 팩토리 표면에 추가될 예정이라는 것도 M1 설계 시 인지. 설계는
네 라운드로 대부분 확정됐고 남은 열린 질문은 `question.md` 3번(개수는
거기도 반복 안 함 — 소스는 `research/debounce-throttle-plan.md` 12절).
**[2026-08-14 추가, 2026-08-19 설계 전부 해소 후 `base/`로 승격]** 시간
기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)도
백로그이지만 위 항목들과는 발단이 다름 — **사용자가 직접 요청한 실제
기능 갭**에서 시작됨(그 문서 13절). 다만 제어 핸들 설계까지 닫히고 나니
실제로 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로
확인돼(같은 절), 위 항목들과 우선순위는 다시 같아짐 — M0/M3를 막지
않고, **M3에서 `Blocker`를 구현할 때 게이티드 노드를 공용 `Gate`
빼두는 것만은 그 시점에 해야 함**(따로 하면 같은 설계를 두 번 함).
주입 op 2개(`setTimeout`/`clearTimeout`)가 백엔드 팩토리 표면에
추가될 예정이라는 것도 M1 설계 시 인지. 남은 열린 질문 없음(구
`question.md` 3번, 전량 해소로 항목 자체가 빠짐).
**[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로
신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll`
(`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때

View file

@ -395,7 +395,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함
(자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈).
**쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고
`:List``reconcile`**값 교체와 `PopOnly`(가칭) 경로만**.
`:List``reconcile`**값 교체와 `Detach` 경로만**.
**여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고
**[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`
`nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의
@ -479,14 +479,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글
아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면
저비용 재사용 경로.
**[2026-08-18 신설] `PopOnly`(가칭) 반환 경로** — `updateFn`
`PopOnly, { old = ..., source = ... }`를 반환하면 그 자리는 **파괴하지
않고 `Parent = nil`로만 내려와** Slot에서 빠지고, 보존은 반환한
userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 반환하면 재마운트).
`Instance.new`/`Destroy` 비용을 아끼는 filter용 경로.
**⚠️ 이름은 가칭이고, "키가 데이터에서 사라졌을 때 PopOnly로 홀드
중이던 요소를 어떻게 처분하는가"는 미결** — 착수 전 결론 필요
(`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저,
**[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** —
`updateFn``Detach, { old = ..., source = ... }`를 반환하면 그
자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고,
보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내
반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용
경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/
slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절).
**⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를
어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론
필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저,
`userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`
`prev`보다 앞이었음).
**`updateFn``index``keyFn`의 raw `index`(원본 `data` 배열
@ -907,10 +909,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
테스트는 2026-08-13 세 번째 세션에 불필요로 해소됨 —
`archive/question-resolved.md` 참고, v2엔 대응 개념 자체가 없음)
- [ ] Slot 형제 순서 보장(다중 백엔드 관점) — Roblox만이면 급하지 않음
- [ ] **[2026-08-14 신설]** 시간 기반 전파 게이트 `Debounce`/`Throttle`
(`research/debounce-throttle-plan.md`) — **M3에서 `Blocker`를 구현할
때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할 것**
(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함).
- [ ] **[2026-08-14 신설, 2026-08-19 설계 전부 해소 후 `base/`로 승격]**
시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)
— 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을
추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` +
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **M3에서 `Blocker`
구현할 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할
것**(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함).
프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음.
주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`,
Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라