quad/.claude/base/blocker-plan.md
qwreey 623c9316fe
docs: Debounce/Throttle 백로그 신설 + "emit은 항상 전파" base 역전 정정
워크트리(worktree-debounce-throttle-plan)에서 네 라운드로 다듬은 결과를
메인의 3단계 분할 구조에 맞춰 필요한 변경만 이식.

## 신설: research/debounce-throttle-plan.md

- Blocker가 이미 쓰는 게이티드 노드의 릴리스 트리거만 타이머로 바꾼 것.
  공개 Blocker API엔 "상류 신호 도착" 통지가 없어 그 위엔 못 얹음 →
  M3에서 게이트를 공용 Gate로 뺄 것.
- 두 도구의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐.
  공개 생성자 2개 + 내부 구현 1개(초안이 옮겨온 lodash식 maxWait 공식엔
  trailing 통과 직후 이중 발화 버그가 있었음).
- quad-base + 주입 op 2개: setTimeout(func, delay) -> Timeout /
  clearTimeout. Roblox는 task.delay/task.cancel로 배선(인자 순서 반대).
  os.clock()은 Luau 표준 라이브러리라 주입 대상 아님(diff 전용).
  Timeout = { __type_timeout: true, _native: any }.

## 역전: emit은 자기 invalid 상태와 무관하게 항상 전파된다

source-state-plan.md의 "이미 invalid였다면 그 아래로 더 전파하지 않는다"가
확정된 Observer 계약(fn이 :Get()을 안 불러도 됨)과 정면 충돌 — 액면대로면
:Get() 안 하는 Observer는 한 번 울고 영구 침묵. architecture.md가 같은
다이아몬드 문제를 pull-recompute로 설명하는 것과도 어긋나 있었음.

정정 모델: invalid는 캐시 낡음 표시일 뿐, 중복 재계산은 pull-recompute+
캐시가 막고 중복 통지는 안 접음(접으려면 Blocker 같은 명시적 게이트).

- source-state-plan.md: 전파 규칙 재작성, "다이아몬드 의존성은 무엇이
  푸는가" 절 신설, Observer 절 상호 참조. 플래튼 기각/:With 빌더 기각
  근거를 캐시 공유로 재작성(두 결론 유지, 근거 강도는 상승)
- architecture.md, blocker-plan.md(전파를 지연시키는 유일한 요소로 위치
  명문화), comparison-fusion-vide.md, framework-comparison-findings.md
- ROADMAP M0 체크리스트: 확인할 것이 정반대가 됨
- luau-test 05 → rewrite-required/(옛 모델을 통과 상태로 검증 중이었음),
  STATUS.md 개수 동기화(rewrite 6→7, done 14→13)
- audit: 05 행 정정 + "12개 전원 통과"를 액면대로 읽지 말라는 경고
- archive/invalidate-dedup-propagation-reversed.md 신설

doc-check: ERROR 0 / WARN 84(작업 전 85).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:22:06 +09:00

6.8 KiB

Blocker — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게

상태: base — research/additional-primitives-plan.md(다른 프레임워크 대비 갭 분석)에서 갈라져 나온 확정 프리미티브. lexical Batch(fn)으로 풀려던 대안은 기각되어 archive/batch-rejected.md로 분리됨 — 이 문서는 확정된 Blocker만 다룬다. base/effect-plan.md(같은 조사에서 나온 다른 확정 프리미티브)와는 서로 무관 — Blocker는 State/Store 작업과 밀접히 얽혀 있고 Effect는 완전히 독립된 요소라 원래도 별개 파일이었어야 했음(2026-08-07 문서 정리에서 한 파일로 합쳤던 걸 다시 분리).

왜 필요한가: state1, state2 -> state3처럼 여러 소스가 한 파생값에 합류할 때, 둘을 한 번에 바꾸면 소비자에게 두 번 전파(재계산+재대입)되는 문제. lexical Batch(fn)(Solid batch()/MobX runInAction()류)으로 풀려던 접근은 코루틴 yield 위에서 구조적으로 위험해 기각됨 — 상세 근거는 archive/batch-rejected.md 참고, 여기서 반복하지 않음. Blocker는 그 문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현해서 이 위험을 구조적으로 우회한다.

store 개발(M3)과 밀접하게 연관됨state:Block(blocker)가 State 위에 얹히는 메소드이므로 base/source-state-plan.md의 Source/State 온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(base/source-state-plan.md "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 State와 같은 마일스톤(ROADMAP.md M3)에서 함께 구현할 것.

[2026-08-14 위치 명문화] Blockerquad에서 emit(무효화 신호) 전파를 지연시킬 수 있는 유일한 요소임. 평범한 State는 신호를 받으면 자기 invalid 상태와 무관하게 항상 아래로 전파하고(base/source-state-plan.md "전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된 게이트뿐 — 지금은 Blocker가 유일하고, 시간 기반 게이트(research/ debounce-throttle-plan.md)가 추가되면 같은 자리에 들어옴. 이걸 못 박아 두는 이유: 과거에 "이미 invalid면 전파를 멈춘다"는 서술이 base에 있었고, 그건 사실상 Blocker가 하는 일을 모든 State에 암묵적으로 심는 것이라 Blocker의 존재 의의를 반쯤 지워버렸음(역전 경위는 archive/invalidate-dedup-propagation-reversed.md).

메커니즘 (확정)

Blocker() -> blocker                -- 생성자
blocker:On() -> self                -- IsBlocked = true로만 설정, 그 외 아무것도 안 함
blocker:Off() -> self               -- IsBlocked = false로 먼저 설정, 그 다음 등록된
                                     -- onunblock 핸들 전부 실행(순서 무관, idempotent)

state:Block(blocker) -> state       -- 새 gated state 반환. **호출되는 즉시**(나중에
                                     -- 처음 블록될 때가 아니라) onunblock 핸들을
                                     -- blocker의 weak 배열에 등록.

gated state의 동작:

  • 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도.
  • blocker.IsBlocked이면: 전파 안 하고 HasBlockedEmit = true만 세팅.
  • blocker.IsBlocked가 아니면: 평소처럼 그냥 전파(투명하게 통과).
  • blocker:Off()가 실행하는 onunblock 핸들은: HasBlockedEmit을 확인해 true면 그제서야 정확히 1회 전파(emit)하고 플래그를 리셋. 이미 false면 아무 것도 안 함(idempotent).

:Get()엔 영향 없음 — 블록은 emit 전파만 지연시킨다. 블록 중이라도 누군가 명시적으로 :Get()하면 그 순간의 실제 값을 정상적으로 계산해서 준다 — base/source-state-plan.md의 "Source 값을 직접 mutate한 뒤 전파 — :Emit()" 절("Get()은 라이브 레퍼런스를 준다" 캐비엇)과 일치.

사용 예시

state1/state2 각각이 아니라 결합된 결과(state3) 하나에만 :Block을 건다:

local blocker = Blocker()
local gated3 = state3:Block(blocker)  -- 소비자는 gated3를 구독

blocker:On()
state1:Set(1)  -- state3 무효화 → gated3로 전파 시도 → 블록됨 → HasBlockedEmit=true
state2:Set(2)  -- state3 무효화 → gated3로 전파 시도 → 이미 true, 그대로
blocker:Off()  -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 번 emit

일반 사용 가이드(확정, 문서화 필수): Block은 파이프라인의 최종 연산 지점(실제로 무거운 계산이 일어나는 derived state, eager 소비자에 가장 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다.

이름 확정

  • 클래스: BlockerObserver/Modifier/Ref와 같은 명사-행위자 네이밍 관례와 일치.
  • Blocker 자신의 토글: On()/Off() -> self (Block()/Unblock() 아님) — state:Block(blocker)가 이미 "배선(wiring)" 동작의 동사로 "Block"을 쓰고 있어서, Blocker 자신의 토글까지 같은 단어를 쓰면 blocker:Block()(블로커를 켠다)과 state:Block(blocker)(state를 이 블로커에 배선한다)가 같은 단어로 다른 두 동작을 가리키게 됨.
  • 필드: IsBlocked(Blocker 자신의 On/Off 상태), HasBlockedEmit (gated state의 대기 플래그, Is/Has 접두어로 불리언임을 바로 알려줌).
  • 메소드: state:Block(blocker) -> state.

재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수

IsBlocked는 카운터가 아니라 단순 불리언이고, 의도적으로 그렇게 둔다. 레퍼런스 카운팅으로 네스팅을 지원할 수도 있었지만, 그러면 "On() 여러 번, Off() 실수로 적게" 같은 버그가 영구 블록으로 조용히 새는 더 위험한 실패 모드를 만든다("poisoned mutex" 트래킹류 해키함도 만들지 않기로 함).

대신 확정된 규칙: 겹치는 배치가 필요하면 각자 새 Blocker 인스턴스를 만들 것 — 하나의 Blocker를 여러 컨텍스트에서 재사용/중첩하지 않는다. Off()는 스태킹 없이 즉시 그 자리에서 꺼진다. 이 제약은 반드시 사용자 문서(API 레퍼런스 수준)에 명시적으로 강조할 것 — 네스팅을 시도하면 조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐.

상태: 핵심 메커니즘+이름 확정. 남은 건 문서화뿐

quadnomicon에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 게 좋은 소재(archive/batch-rejected.md와 나란히 인용).