에이전트 권고("빈 배치 = 무조건 통지")를 사용자가 기각. 빈 배치는 "이미 다
던져서 더 던질 게 없다"는 뜻이고, 같은 카운트가 두 번 흘러든 emit을 전파하지
않는 것과 같은 자리다. 그걸 흘리는 건 표면적으로 State 중간에 Source:Emit을
추가하는 격이라 Gate의 성격과 안 맞는다.
확정: next(withheld) == nil이면 통지 자체를 안 한다. 새 규칙이 아니라 기존
계약의 일반화임을 확인 — blocker-plan.md가 이미 "HasBlockedEmit이 false면
emit 값과 무관하게 아무 것도 안 함(idempotent)"으로 확정해뒀고
HasBlockedEmit은 next(withheld) ~= nil의 특수형이다. Debounce/Throttle도
if pending일 때만 passThrough()를 부른다.
따름정리 — Effect(fn, ...deps)의 설치 구간 억제가 Gate 소비자에서 빠졌다.
설치 구간엔 어떤 Set도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가
내보낼 것 자체가 없고, Effect 내부 플래그면 충분하다. effect-plan.md에서
"⚠️ 억제 장치의 모양은 Gate 설계에 딸려 있다"와 우선순위 문단의 "Gate보다
뒤다"라는 순서 제약이 같이 빠졌다.
이로써 Gate에 사용자 판단이 필요한 항목은 없다 — 남은 건 생명주기 계약과
M2 범위뿐이고 둘 다 구현 시 결정. 처리 전량은 V절. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
21 KiB
Gate — emit을 가로채는 게이트 노드 (2026-08-21 확정)
상태: 표면 확정. Blocker/Debounce/Throttle이 공유하는 게이팅
메커니즘을 state:Gate(setup) 메소드로 확정했다 — "Gate 는 따로 프리미티브
없이 state:Gate( (emit) -> ()->() ) 처럼 선언되고 마치 Compute 처럼
GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 배선을 따른다
← 동의합니다 해당 방법대로 확정하면 됩니다." 구현은 M2("게이팅 먼저"
결정, ROADMAP.md). 남은 것은 아래 "아직 안 정한 것"의 생명주기와 M2 범위뿐이고, 둘 다
구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다 — /code-review high가 잡았던 4번(유보된 emit이 싣는 출처)은 같은
날 emit(self) + 흡수 집합으로 닫혔고, setup 시그니처는 안 바뀌었다.
⚠️ 처음 방향이 한 번 바뀌었다. 신설 당시엔 *"공용 Gate 프리미티브를 꺼내고
Blocker가 그걸 컴포지션한다"*였는데, 확정된 형태는 프리미티브를 따로 안
만들고 State 메소드 하나로 끝낸다이다 — "Gate 프리미티브를 만들고 Blocker 가
컴포지션 하는걸 생각했는데, 그럴 필요가 없네요. :Gate 는 또 Apply 에서 쓸만한
표면을 주기도 하구요." 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그
이전 시점 표현이니 이 배너 기준으로 읽을 것.
한 줄: Blocker가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, 상류
emit을 가로채 내려보낼지 말지를 정책이 정하는 노드를 state:Gate(setup)
공개 메소드로 낸다(탑레벨 생성자가 아니라 — 위 배너 참고).
Blocker/Debounce/Throttle이 그 위에 얹히는 서로 다른 정책이 된다.
왜 지금인가 — 두 갈래가 같은 자리를 가리켰다
CR-3(마일스톤 순서) —Dispatch.drive의 배치 등록이 Blocker 게이팅을 전제하므로 M2가 M3의Blocker.luau에 구조적으로 의존한다. 사용자 결정: 게이팅을 먼저 만든다.DT-4(Debounce/Throttle) — 공개BlockerAPI 위에는 시간 기반 게이트를 못 얹는다.base/debounce-throttle-plan.md가 이미 "게이티드 노드를 내부 공용Gate로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, M3에서 Blocker를 만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다.
사용자 논거(DT-4) — Blocker + Observer 조합으로는 왜 안 되는가:
"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐
후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer
가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가
되면 이를 전혀 못 지키기 때문." → 게이트가 emit 경로 자체에 끼어들어야
하고, 바깥에서 관측만 해서는 순서를 보장할 수 없다.
공개 여부: 사용자 판단 — "이 API가 비공개일 이유는 없어보인다." 즉
내부 배관이 아니라 공개 표면으로 낸다. [2026-08-21] 다만 그 표면은
탑레벨 프리미티브가 아니라 State 메소드 :Gate 다(아래 2번).
제안된 모양 (사용자 스케치 그대로)
Gate(function(emit)
-- 이 `emit`은 setup 밖으로 캡처해 **언제든** 부를 수 있다(타이머 콜백 등).
return function()
-- 상류 emit이 도착할 때마다 호출된다.
-- 여기서 `emit()`을 부를지 말지는 정책 마음.
end
end)
setup(emit) -> onUpstreamEmit2단 구조. 바깥 함수는 게이트 인스턴스가 만들어질 때 1회, 반환된 함수는 상류 emit마다.Blocker는 이 위의 정책 하나가 된다 — "켜져 있으면emit()을 안 부르고 플래그만 세워두고, 꺼질 때 한 번 부른다"(HasBlockedEmit이 그 플래그).Debounce/Throttle도 정책 — 타이머를 걸고 창이 끝날 때emit().
아직 안 정한 것 (사용자 판단 필요)
-
[2026-08-21 해소] 이름 —
Gate. 원래 걸림돌은 *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다"*였는데, 탑레벨 프리미티브를 안 만들기로 하면서 문제 자체가 사라졌다 — 이름이 놓이는 자리가Blocker()같은 생성자가 아니라state:Gate(...)메소드이고, 메소드 자리에서Gate는:With/:Compute/:Observer와 나란히 자연스럽다. 노드 타입 이름은ComputeNode와 짝을 맞춘GateNode. -
[2026-08-21 해소]
:Apply가 아니라 State의 메소드다 —state:Gate(setup). 확정 형태:-- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ()) local gated = state:Gate(setup) -- ComputeNode처럼 GateNode를 하나 만든다Blocker는 그 위에 얹히는 별개 프리미티브로,state:Block(blocker)가 내부에서 이 배선을 그대로 쓴다. 탑레벨Gate(...)생성자는 안 만든다.:Gate가 메소드라고:Apply와 배타적인 것도 아니다 — 사용자 지적대로 ":Gate는 또 Apply 에서 쓸만한 표면을 주기도" 하므로,Debounce{...}같은 유저랜드 팩토리가 내부에서s:Gate(policy)를 부르는 형태로state:Apply(Debounce{...})관용구가 그대로 성립한다.사용자 확정: "gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문, state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."
- 정확한 경계:
Apply는factory(self)일 뿐이라(base/source-state-plan.md의 "state:Apply(factory)" 절) 팩토리가 노드를 만드는 것 자체는 금지가 아니다 — 확정 예시의capAt(100)도:With노드를 만든다. 갈리는 지점은 누가 프리미티브인가다::With/:Compute처럼 전파 경로에 새 종류의 노드를 끼우는 것은 State의 메소드, 그 프리미티브들을 조합한 유저랜드 팩토리는:Apply.Gate는 전자다. - 그래서
Debounce/Throttle은:Apply그대로 둔다 —Debounce{...}가 돌려주는 팩토리가 내부에서s:Gate(policy)를 부르면 되므로base/debounce-throttle-plan.md의 확정 관용구는 안 건드려도 된다. Blocker배선 문제도 같이 사라진다.base/blocker-plan.md가 이미state:Block(blocker) -> state(새 gated state 반환) 라는 메소드로 확정해뒀으므로,Block이 내부에서self:Gate(blocker의 정책)을 부르는 것으로 끝난다 —blocker객체를Apply에 넘길 일이 없다.__call은 안 쓴다. 사용자도 *"이상적이여 보이지는 않음"*이라 했고, 타입 쪽 근거가 하나 더 있다 —__call테이블이 Luau에서(State<T>) -> U함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).Apply를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시 되살아나면luau-test스파이크 한 개로 판정할 것.- 2단 구조는 그대로 유효하다 — 사용자 관찰("Gate 의 callback 으로 얻어진
emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능") 대로, 바깥 함수가
그 노드의
emit을 캡처하고 반환 클로저가 상류 emit마다 정책을 태운다.Blocker처럼 여러 노드가 공유하는 정책은 공유 상태를 바깥 객체가 들고, 노드별emit만 2단 구조로 받아 등록하면 된다.
- 정확한 경계:
-
[2026-08-21 확정]
Get()과의 관계 — 값이 아니라 통지만 막는다.Blocker가 이미 그렇고(base/blocker-plan.md의 ":Get()엔 영향 없음"),Debounce/Throttle도 emit-gate로 확정돼 있어(base/debounce-throttle-plan.md§4)Gate도 같은 계약으로 통일한다. 공개 계약 문구: "게이트를 통과하지 않은 값도:Get()으로는 보인다."base/state-epoch-plan.md가 이 계약에 의존한다 — 그 문서 §5의 3번이 "게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기 위해서다. -
[2026-08-21 해소] 게이트가 유보했다 내보내는 emit —
emit(self)+ 흡수 집합. 문제는 실재했다: 확정setup은(emit: () -> ()) -> (() -> ())라 양쪽 다 source를 안 받는데base/state-epoch-plan.md의 수신 규칙은 전부[source]키로 판정하므로,blocker:Off()가 묶어뒀던 배치 emit이 아무 원천도 못 지목한 채 도착해 하류에서 삼켜진다(2026-08-21/code-review high발견).확정 기제(사용자 안, 에이전트가 냈던 (a)
nil전체 확인 / (b) 소스마다 개별 emit은 기각 — 각각 O(1) 판정을 깨거나Blocker의 "정확히 1회"를 깬다):GateNode가 자기를 거쳐간 소스 집합을 들고 있는다 —withheld : { [source] : true }, weak key(소스 맵과 같은 이유,state-epoch-plan.md§5의 5번).- ⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다. 상류 emit이
오면 정책을 실행하기 전에 무조건 그 출처를
withheld에 넣고, 그 다음 정책을 실행한다. 정책이emit()을 부르면 게이트가 하류에 전파하고, 안 부르면 집합에 그대로 쌓인다.- ⚠️ [2026-08-21
/code-review high] "무조건"은 정책의 통과/유보와 무관하게라는 뜻이지 수신 규칙을 건너뛴다는 뜻이 아니다. 게이트도 평범한 노드처럼state-epoch-plan.md§2의 규칙 1~3을 먼저 적용하고, 3번(둘 다 같음)으로 삼켜진 emit은 정책도 안 돌고 집합에도 안 들어간다. 안 그러면 다이아몬드(A→B→G,A→C→G)에서A:Set()한 번에 정책이 두 번 돌아,Throttle의 leading 통과 직후 두 번째 emit이pending을 세워 이미 전달한 변경에 대한 유령 trailing emit이 나간다.
- ⚠️ [2026-08-21
- ⭐⭐ 정책이 받는
emit은 "이 값을 내보내라"가 아니라 "쌓인 걸 지금 흘려보내라"(flush) — 페이로드를 정책이 정하지 않는다. 그래서 배치를 떼어내는 것도 그 핸들 안에서 일어난다.base/debounce-throttle-plan.md가 이미 같은 것을gate:passThrough()("invalid 세팅 + 아래로 1회 전파")로 부르고 있다. - 전파 페이로드는
withheld자체가 아니라 flush 진입 시점에 떼어낸 스냅샷이다. [2026-08-21 정정] 여기 한때 "전파가 반환된 뒤에table.clear"라고 적었는데 그게 틀렸다 — 전파가 스택에 남아 있는 동안 제어가 같은 게이트로 되돌아오면(하류 Observer가 상류를:Set(),debounce-throttle-plan.md의onWindowEnd주석이 이미 대비하는 경우) 중첩 flush의clear가 돌아 바깥 전파의 남은 갈래가 빈 집합을 받는다. 모델이 아니라 그 의사코드가 문제였다 —emit()이 flush인 이상 들어가는 순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다:
하류가 순회하는 것은local batch = self._withheld self._withheld = {} -- 새 테이블. clear가 아니다 emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다gate._withheld가 아니라 받은batch다. 중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 에포크가 중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 count가 이미 최신이라 규칙 3으로 삼켜진다. - 그래서 게이트는 언제나 자기(와 그 배치)를 출처로 낸다 — 그냥 통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: "후행 노드들은 한개가 지연된거로 생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가 없습니다." 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정 규칙은 완전히 같다.
- 하류가 평범한 노드면, 출처가
GateNode일 때 그 집합을 순회하며 각 소스에 평소 규칙(1~3)을 적용하고, 하나라도 걸리면 받은 출처를 그대로 더 아래로 넘긴다(state-epoch-plan.md§2). - ⭐ [2026-08-21 신설] 하류가 또 다른 게이트면 — 받은 집합을 풀어
자기
withheld에 합친다. 게이트가 게이트 emit을 받는 경우가 정의돼 있지 않던 구멍이었다(사용자 발견). 출처를 그대로 넘기면 안 된다 — 상류 게이트가 넘기는 배치는 그 전파에만 쓰이는 일회성 스냅샷이라, 하류 게이트가 그 참조만 들고 유보했다가 나중에 풀면 이미 지나간 배치를 내보내게 된다. 그래서 수신 시점에 풀어서 옮겨 담아야 한다:
게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이 먼저 풀리든 정보가 안 샌다.-- 출처가 Source면 그 하나를, 게이트 배치면 그 배치 전부를 편다 for source in unfold(origin) do self._withheld[source] = true end -- 그 다음 평소대로 정책 실행 - 게이트의
sourceEmitMap은 수신 때가 아니라 실제로 전파할 때 갱신한다 (집합 전체에 대해 한꺼번에). 그래야 "내가 하류로 던진 에포크"라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 에포크가 다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에 있으므로 무해하다. - ⭐ [2026-08-21
/code-review high] emit 없이 푸는 경로는 집합을 버려야 한다.blocker:OffWithoutEmit()은 정의상 "밀린 전파를 버리며 끈다"(base/blocker-plan.md)이므로, 그 경로도withheld를 비운다 (전파는 안 하고 새 테이블로 스왑). 안 그러면Dispatch.drive의 배치 게이팅이 매 프레임On()→ … →OffWithoutEmit()을 도는 동안 집합이 단조 증가하고, 나중에 아무 소스나 한 번 통과하는 순간 버리기로 했던 옛 소스들이 같이 실려 나가 하류가 폐기된 통지로 무효화된다. - 그렇게 비우고 나면 하류의
sourceEmitMap은 뒤에 남지만, 그 소스의 다음 진짜 emit이 규칙 1/2로 걸려 스스로 낫는다 — 별도 조치 불필요.
⭐ 그래서
setup시그니처는 안 바뀐다. 집합을 채우는 건 정책이 아니라 노드이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화). 정책은 소스를 몰라도 되고,Throttle처럼 나중에 타이머에서emit()을 부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다. -
생명주기. 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 — 지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
debounce-throttle-plan.md5-4).Gate자체에Flush/Cancel같은 표면을 둘지, 그건 정책(Debounce)만의 것으로 둘지. -
[2026-08-21 정리 — 열린 항목 아님] 재진입. 여기 한때 *"
onUpstreamEmit안에서 같은 게이트의emit()을 재귀적으로 부르는 경우"*라고 적혀 있었는데 잘못 옮긴 서술이었다(사용자 지적).blocker-plan.md의 "재진입(네스팅)" 절이 말하는 건 같은Blocker인스턴스를 중첩해On()/Off()하는 것 이고, 정책이emit()을 호출하는 것과는 무관하다 — 애초에 정책이 flush를 부르는 건 재귀가 아니라 평범한 통과 경로다. 지금 계약은 셋으로 정리된다:- 끝나지 않는 되먹임은 UB.
base/dispatch-core-plan.md의 2026-08-04 확정 원칙 그대로 — "일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로 간주". 게이트가 따로 가드를 두지 않는다. - 유한한 재진입은 지원한다. 전파 도중 소비자가 동기적으로 상류를
:Set()하는 건 이미 대비된 경우이고(debounce-throttle-plan.md의onWindowEnd주석), 게이트 쪽에서 그걸 안전하게 만드는 장치가 위 4번의 flush 진입 시 스왑이다. - 같은 게이트/
Blocker인스턴스를 중첩해 쓰지 않는다 —Blocker의 기존 규칙이 그대로 적용된다(겹치는 배치는 새 인스턴스).
- 끝나지 않는 되먹임은 UB.
-
[2026-08-21 해소]
Effect(fn, ...deps)의 최초 1회 억제는Gate소비자가 아니다. 한때 이 용례까지 게이트가 커버해야 한다고 적어뒀으나, 위 8번(빈 배치는 통지 안 함)으로 성립하지 않는 게 확인됐다 — 설치 구간엔 어떤Set도 안 일어나 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 없다.Effect가 자기 내부 플래그로 설치 중 발화를 누르고 마지막에 한 번 직접 실행하면 되고, 새 메커니즘이 필요 없다.base/effect-plan.md의 그 항목에 달려 있던 "⚠️Gate설계에 딸려 있다"도 같이 해소됐다. 아래는 원 서술: 2026-08-21 5라운드C-6에서 확정된 다중 의존성Effect는, 의존성마다 구독을 걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로 설치 구간 동안 발화를 눌러뒀다가 마지막에 한 번만 실행해야 한다(base/effect-plan.md의 그 절). 즉Gate(또는Blocker의 직접 사용)가 "설치 구간을 감싸 최초 발화를 한 번으로 접는" 용례까지 커버해야 한다 — 설계할 때 이 소비자를 같이 볼 것. -
[2026-08-21 해소] 소스 없는 emit(빈 배치) — 아무것도 안 한다.
/code-review high가 "정책이 상류 신호와 무관하게 flush를 부르면 빈 배치가 나가 하류가 조용히 삼킨다"를 문제로 제기했고, 에이전트는 "빈 배치 = 무조건 통지"를 권고했다. 사용자 기각:"빈 배치면 이미 하류로 한번 다 던져서 더 던질게 없다는 의미입니다. 마치 두번 흘러들어온 같은 카운트의 emit 과 유사한데요. 그건 전파 안 합니다. … 애초에 Gate 는 중간에 emit 을 할 수 있는 핸들을 노출하는,
Source:Emit같은걸 주는 요소도 아니고, 쌓아두다 뒤로 넘기는건데, 쌓아둔것 자체가 없는데 뒤로 넘긴다는건 이상합니다."확정:
next(withheld) == nil이면 통지 자체를 안 한다. 그래야 다른 State와 동작이 같아진다 — 빈 배치를 흘리는 건 표면적으로 State 중간에Source:Emit을 추가하는 격이고,Gate는 그런 요소가 아니다.- 새 규칙이 아니라 기존 계약의 일반화다.
base/blocker-plan.md는 이미 *"이미HasBlockedEmit이 false면emit값과 무관하게 아무 것도 안 함 (idempotent)"*이라고 확정해뒀다 — 즉HasBlockedEmit은next(withheld) ~= nil의 특수형이다.Debounce/Throttle도if pending일 때만passThrough()를 부른다 (base/debounce-throttle-plan.md의onWindowEnd). - 3번 항목의 "
emit은 언제든 부를 수 있다"는 그대로 유효하다 — 언제 불러도 되지만, 쌓인 게 없으면 그 호출은 no-op이라는 뜻으로 읽는다. - 따름정리:
Effect(fn, ...deps)의 설치 구간 억제는Gate소비자가 아니다 — 아래 7번 참고.
- 새 규칙이 아니라 기존 계약의 일반화다.
-
M2 범위. M2에
Gate만 넣고Blocker는 M3에 그대로 둘지, 아니면Blocker까지 같이 앞당길지.Dispatch.drive의 배치 등록이 실제로 쓰는 건blocker:On()/OffWithoutEmit()/IsOn()이므로(배치 게이팅 절), 최소한 그 세 메서드가 도는 형태까지는 M2에 필요하다.
관련 문서
base/blocker-plan.md— 현행Blocker확정(이 문서가 일반화하려는 대상).base/debounce-throttle-plan.md— "공개BlockerAPI 위엔 못 얹음" 절이Gate일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량.base/dispatch-core-plan.md— "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 M2가 실제로 요구하는 표면.ROADMAP.mdM2/M3.