콘텐츠로 이동

Debounce / Throttle

상류의 신호를 시간으로 걸러 하류 통지를 미루는 게이트 팩토리입니다. Blocker+state:Gate와 백엔드가 주입한 시간 op 위의 순수 슈거로, 새 코어 메커니즘을 만들지 않습니다.

이 페이지의 심볼: q.Debounce{...} · q.Throttle{...} · factory:Flush() · factory:Cancel() · handle:Flush() · handle:Cancel()

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")
local QuadTypes = require("@game/ReplicatedStorage/roblox_packages/quad_types") -- 설정 모듈이 다시 내보내지 않는 타입(`QuadTypes.GateHandle` 등)
type State<T> = q.State<T>

Debounce{...} / Throttle{...}는 팩토리(TimedGate)를 돌려주고, state:Apply(factory)로 붙입니다. :Apply 한 번이 게이트 노드 하나를 만듭니다 — 자기 Blocker, 자기 타이머를 가집니다. 그래서 한 팩토리를 여러 State에 붙이면 게이트도 그만큼 생깁니다.

  • Debounce: 상류 신호가 올 때마다 창을 다시 엽니다 — 신호가 멎은 뒤에 통과.
  • Throttle: 창 길이가 고정입니다 — 신호가 창을 리셋하지 못합니다.
  • 구현은 하나이고 이 리셋 비트 하나만 다릅니다.
  • 같은 시각에 신호가 둘 오면 Throttle(Leading = true)은 첫 신호를 바로 통과시키고 둘째는 보류했다가 창이 끝날 때 통과시킵니다([2026-09-27 mock 실측]).
  • 창 끝과 같은 시각에 온 신호의 순서는 엔진 스케줄러에 달려 있습니다 — 계약이 아닙니다. 창 끝 타이머가 먼저 도는지 신호가 먼저 도는지에 기대지 마세요.

게이트는 통지만 유보합니다. 창이 열려 있는 동안에도 :Get()은 언제나 상류의 최신 값을 돌려줍니다 — 값을 가리는 게 아니라 하류 알림을 미루는 것입니다.

게이트를 이어 붙이면 통지가 한 번 더 올 수 있습니다. source:Apply(q.Debounce { Time = 0.5 }):Apply(q.Debounce { Time = 0.25 })처럼 게이트 뒤에 게이트를 잇고 뒤쪽 게이트에 값을 읽지 않는 Observer를 직접 붙이면, 새 값이 없는데도 통지를 한 번 더 받고 뒤쪽 창도 다시 열릴 수 있습니다(콜백이 :Get()을 부르면 한 번 — [2026-09-27 mock 실측]). 하류 State·Effect는 같은 값을 삼키므로 값과 재실행에는 영향이 없습니다. Blocker를 이을 때와 같은 규칙입니다 — 통지 횟수가 아니라 값에 기대세요.

통과시키는 도중 하류가 상류를 다시 :Set하면 — 하류 Effect가 자기 의존성을 되쓰는 경우(정의된 경로)엔 그 신호가 새로 모이기 시작한 보류 집합에 들어가 정책을 한 번 더 거칩니다 — 그 순간의 창 상태에 따라 바로 나가거나 다음 통과까지 보류되고, 창은 하나뿐입니다. 하류 Observer가 상류를 되먹이는 것은 정의되지 않은 동작입니다(Observer).

tostring은 이름과 창 길이를 보여줍니다 — Debounce(Time=0.3).

수명 — 게이트 노드는 상류를 약하게 구독하고 대기 타이머가 자기를 붙듭니다. 하류(호스트 인스턴스)가 파괴되면 마지막 상류 신호 뒤 최대 2×Time 안에 수거됩니다 — 단 상류가 Time보다 촘촘하게 계속 발화하면 창이 계속 다시 열려 그동안은 살아서 창 처리를 되풀이합니다([2026-09-27 mock 실측] — 상류를 멈추면 3초 안에 0). 모듈 수명의 상류(RunService 같은 것을 담은 Source)에 게이트를 걸었다면 호스트를 버릴 때 핸들 :Cancel()을 부르세요(예: OnDestroyed에서).


시그니처

Debounce: (opts: DebounceOptions) -> TimedGate
export type DebounceOptions = {
read Time: number | StateMarker<number>,
read Leading: boolean?, -- 기본 false
read Trailing: boolean?, -- 기본 true
read MaxTime: (number | StateMarker<number>)?,
read Handle: Ref<GateHandle?>?,
}

반환 — TimedGate 팩토리. state:Apply(factory)의 결과 타입은 상류와 같은 State<T>이고 호출부가 명시합니다.

시그니처

Throttle: (opts: ThrottleOptions) -> TimedGate
export type ThrottleOptions = {
read Time: number | StateMarker<number>,
read Leading: boolean?, -- 기본 true
read Trailing: boolean?, -- 기본 true
read Handle: Ref<GateHandle?>?,
}

MaxTime이 없다는 것만 DebounceOptions와 다릅니다.


모르는 키는 조용히 무시됩니다. q.debug = true면 모르는 옵션 키(오타·옛 이름)를 한 줄로 알려 줍니다 — 정본은 q.debug.

옵션 타입 Debounce 기본값 Throttle 기본값 설명
Time number | State<number> 필수 필수 창 길이(초). 음수·NaN·math.huge 거부(무한 창은 Flush로만 닫혀 쓸모가 없습니다). 0은 허용되지만 “즉시”가 아니라 백엔드 타이머의 다음 재개점(Roblox task.delay(0)은 다음 프레임 근방)으로 미뤄질 수 있습니다 — mock 백엔드에선 같은 advanceTime(0) 안에서 발화합니다
Leading boolean? false true 창이 열릴 때 첫 신호를 즉시 통과시킬지
Trailing boolean? true true 창이 닫힐 때 보류분을 통과시킬지
MaxTime (number | State<number>)? nil 없는 옵션 신호가 안 끊겨도, 보류가 시작된 뒤 최대 이만큼 안에 강제 통과. 강제 통과는 창을 다시 열지 않으므로 MaxTime < Time이면 통과 간격이 Time보다 짧아지고(Time 1·MaxTime 0.5·0.25초 간격 신호 → 0.5초마다), MaxTime 0이면 신호마다 다음 타이머 틱에 통과합니다([2026-09-27 mock 실측]). 걸린 캡은 원래 지연을 유지하고 다음 캡부터 새 State 값을 씁니다
Handle Ref<GateHandle?>? nil nil 수동 제어 핸들을 받을 Ref
  • 기본값을 적용한 결과 Leading과 Trailing이 둘 다 false가 되면 에러입니다 — 아무것도 통과하지 못하므로. Debounce는 Leading의 기본값이 false라서 Trailing = false 하나만 줘도 여기에 해당합니다 — 그때는 Leading = true를 같이 주세요.
  • MaxTime은 Debounce 전용입니다. Throttle은 이미 Time마다 통과시키므로 옵션 자체가 거부됩니다.
  • MaxTime 타이머는 뭔가 보류됐고 Trailing이 그걸 통과시킬 수 있을 때만 걸립니다. Trailing = false인 Debounce에 MaxTime을 줘도 아무 일도 하지 않습니다 — 읽지도 않으므로 그 자리의 State는 검증되지도, 계산되지도 않습니다. 그리고 그 타이머는 통과 시점이 아니라 그다음 보류되는 신호부터 잽니다 — 신호가 쉬지 않고 들어올 때 실제 통과 간격은 MaxTime에 “통과 뒤 다음 신호까지의 틈”이 더해집니다(0.1초마다 신호에 MaxTime = 1이면 1.0~1.1초).
  • Time/MaxTime에 State를 줘도 구독하지 않습니다 — 필요할 때 읽습니다. Time은 창을 열 때마다(신호가 창을 열 때, 창 끝 통과 뒤 다시 열 때, Flush 뒤) 읽고, MaxTime은 상류 신호가 들어올 때 읽습니다. 이미 걸린 타이머는 자기 지연을 유지하므로, 값을 바꾸면 다음에 여는 창부터 반영됩니다(신호가 없어도 창 끝 통과 뒤 다시 여는 창은 새 값으로 열립니다).

옵션 게이트(팩토리를 부르는 줄에서 던집니다). 이름 자리(Debounce/Throttle)는 부른 쪽에 따라 바뀝니다.

Quad0045 Debounce: options must be a table (got nil)
Quad0040 Debounce: Time is required (a number of seconds or a State<number>)
Quad0041 Debounce: Time must be a non-negative number less than math.huge, or a State<number> (got -1)
Quad0042 Debounce: Leading must be a boolean (got number)
Quad0047 Debounce: Handle must be a Ref (got table)
Quad0048 Debounce: Leading and Trailing both false would pass nothing through
Quad0046 Throttle: MaxTime is Debounce-only (a throttle already passes every Time)

Time/MaxTime에 State를 준 경우엔 값이 실제로 읽히는 시점에 한 번 더 검사합니다. 신호가 여는 창의 Time과 MaxTime은 그 :Set 줄에서, Flush 뒤 다시 여는 창은 그 Flush 줄에서, 창 끝 통과 뒤 다시 여는 창은 타이머 콜백 안에서 던집니다(Roblox라면 task 콜백의 에러 출력). 던진 뒤의 상태는 계약이 아니고 관측입니다(State의 “던진 콜백은 감싸지 않는다” 규칙 그대로): :Set 줄에서 던지면 상태를 바꾸기 전이라 게이트는 그대로이고, 그 신호의 출처는 버려지지 않습니다(leading이면 이미 통과했고, 아니면 보류 집합에 남아 다음 통과에 실려 나갑니다) — 던진 자리가 상류 :Set의 전파 파동 안이라 같은 원천의 다른 구독자 중 뒤에 오는 것은 그 신호를 받지 못합니다. 타이머 콜백에서 던지면 그 창의 보류분은 이미 나간 뒤이고, 게이트는 다음 신호까지 창 없이 막힌 채 남습니다 — 다음 신호는 보류됐다가 그 신호가 연 창 끝에 나가며 회복됩니다(Leading 게이트는 leading 한 번을 잃습니다). 이때의 메시지는 조금 다릅니다(그 자리엔 State<number> 갈래가 없으므로).

Quad0039 Debounce: Time must be a non-negative number less than math.huge (got string)

:Apply 대상이 State가 아니면 그 :Apply 줄에서 막습니다.

Quad0018 Debounce: Apply target must be a State (got table)

Handle에 q.Ref<<QuadTypes.GateHandle?>>(nil)를 넘기면 :Apply 시점에 그 Ref가 제어 핸들로 채워집니다(마운트 시점이 아닙니다).

export type GateHandle = {
Flush: (self: GateHandle) -> (),
Cancel: (self: GateHandle) -> (),
}

Ref 하나는 :Apply 하나를 가리킵니다. 이미 채워진 Ref를 다시 넘기면 — 팩토리를 재사용하며 같은 Handle을 쓰는 경우가 전형적입니다 — 그 :Apply 자리에서 에러가 납니다. 기본값이 nil이 아닌 Ref도 같은 이유로 거부됩니다.

Quad0044 Debounce: Handle is already filled - one Handle Ref per :Apply (make a new Ref, or drop Handle and use the factory's :Flush()/:Cancel() broadcast; a Ref with a non-nil default is rejected the same way)

여러 게이트를 한꺼번에 제어하려면 Handle 대신 팩토리 브로드캐스트를 쓰십시오.

시그니처 — Flush: (self: GateHandle) -> ()

보류분이 있으면 지금 통과시키고 창을 다시 엽니다. 보류분이 없으면 진짜 no-op입니다 — 돌고 있는 창을 건드리지 않습니다.

시그니처 — Cancel: (self: GateHandle) -> ()

보류분을 버리고 idle로 돌아갑니다. 전파가 일어나지 않습니다. 상류의 값 자체는 그대로이므로 :Get()은 여전히 최신 값입니다 — 버려지는 것은 통지입니다.

flush 도중에 부르지 마세요. 이 게이트가 값을 내보내는 도중(창 끝 통과·MaxTime·handle:Flush(), 그리고 leading 통과 — 상류 :Set 줄 안에서 바로 내보내는 것) 그 통지를 받은 하류 콜백 안에서 같은 게이트의 Cancel이나 Flush를 부르는 것은 정의되지 않은 동작입니다(팩토리 브로드캐스트로 부르는 것도 같습니다). 관측(계약 아님): 지금은 Cancel이 끝난 뒤 내보내던 쪽이 창을 다시 열어, 다음 leading 신호가 Time 하나를 더 기다렸다가 저절로 돌아옵니다. 취소는 그 콜백 밖에서 하세요. q.debug를 켜면 이런 Cancel과 Flush가 한 줄로 드러납니다(동작은 그대로) — Debounce: handle:Cancel() was called from a callback this gate's own flush reached (undefined - the window may reopen); call it outside that callback(Flush면 handle:Flush(); Flush와 leading 통과도 알립니다).


export type TimedGate = {
Flush: (self: TimedGate) -> (),
Cancel: (self: TimedGate) -> (),
__apply: (self: any, state: any) -> any,
}

그 팩토리가 만든 모든 게이트에 Flush를 전파합니다. 팩토리는 자기가 만든 게이트를 약한 키로만 붙들고 있으므로, 이 목록이 게이트를 살려두지는 않습니다.

같은 방식으로 모든 게이트에 Cancel을 전파합니다.


-- 검색창 디바운스(0.3초, 1초마다는 강제 통과) + 수동 제어 핸들
local searchInput = q.Source("")
local handle = q.Ref<<QuadTypes.GateHandle?>>(nil)
local debounced: State<string> = searchInput:Apply(q.Debounce {
Time = 0.3,
MaxTime = 1.0,
Handle = handle,
})
-- Observer 콜백은 값이 아니라 대상 State 핸들을 받는다 — 값은 :Get()으로 읽는다.
-- 구독을 유지하려면 :Subscribe()(또는 숫자 키 자리에 넣어 인스턴스에 바인딩)해야 한다.
debounced:Observer(function(target)
print("API 검색 실행:", target:Get())
end):Subscribe()
searchInput:Set("a")
searchInput:Set("ab") -- 창 안이라 통지 없음. 다만 debounced:Get()은 이미 "ab"
-- 0.3초 뒤 "ab"가 통지된다
handle:Unwrap():Flush() -- 기다리지 않고 지금 내보낸다(보류분이 없으면 no-op)
handle:Unwrap():Cancel() -- 보류분을 버린다(통지 없음)
-- 스크롤 위치 스로틀(0.1초 고정 창) — 팩토리 브로드캐스트로 한꺼번에 제어
local scrollGate = q.Throttle { Time = 0.1 }
local scrollRaw = q.Source(0)
local scrollPos: State<number> = scrollRaw:Apply(scrollGate)
scrollGate:Flush() -- 이 팩토리가 만든 게이트 전부
scrollGate:Cancel()