quad/.claude/base/bind-system-plan.md
qwreey 1a612ecfa0
decide(base): Modifier.Override를 Overridden으로 이름 확정
Add/Remove→Added/Removed, Merge→Merged와 같은 분사형 네이밍 컨벤션을
Override에도 적용하되, override가 불규칙동사임을 반영해 정확한 과거분사
Overridden을 채택(Overrided는 오기). question.md 용어 재검토 목록에서
제거하고 관련 base/research 문서 전반에 반영.
2026-08-08 03:37:32 +09:00

136 KiB

Bind 시스템 — pluggable key/value 핸들러 (base로 승격됨)

상태: base — 핵심 디스패치 모델(process/retract, 핸들러 4종 계약, Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 엔진은 quad-base가 인터페이스로 소유, quad-roblox는 실제 구현만)까지 전부 2026-08-04 세션에서 확정되어 research/에서 승격됨(base/architecture.md의 "구현 착수: 소스 트리 구조 확정" 절 참고). 남은 건 세부 시그니처(dependency array API) 뿐 — 구현 단계에서 자연히 정리됨. 원본: .claude/initreq/raw-userinput.md "key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 reference/quad-v1-architecture.md ("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 .claude/initreq/tbox (레지스트리)와 Fusion/Vide 비교는 reference/comparison-fusion-vide.md 참고.

문제

v1의 ProcessQuadProperty(.claude/initreq/quad/src/class.lua:134-214)는 숫자 키(children/style) vs 문자열 키(prop/event) vs __type 태그 테이블 (register/linker/style)을 하드코딩된 if/elseif 체인으로 구분한다. 새 특수 키 ([Attribute "X"], [Tag ""], PropertyChangedEvent "" 등)를 추가하려면 이 중앙 함수 자체를 고쳐야 한다 — 라이브러리로서 확장 불가능한 구조.

핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것)

핸들러는 다음 4개를 제공하는 등록 가능한 객체:

  • isHandlable(inst, key, value): boolean — 이 핸들러가 이 inst/key/value 조합을 처리할 수 있는지 판별하는 predicate. 부작용 없이, 빠르게 — tbox의 type-check/constraint-check 분리 원칙(.claude/initreq/tbox/ CLAUDE.md의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로 적용: isHandlable은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 유효성 검사는 핸들러가 선택된 이후 별도 단계에서. inst도 받음 (2026-08-07 여덟 번째 세션 정정, 원래 (key,value)뿐이었음)process/retract는 처음부터 항상 inst를 받았는데("모든 핸들러는 대상 Instance를 직접, 항상 받는다", 아래 "확정된 디스패치 모델" 절) isHandlable만 예외였던 게 애초에 약간의 불일치. 지금 당장 inst에 따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른 백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러 계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 — 사용자 판단으로 확정.
  • priority: number — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 action() 우선순위)보다 일반화된 열린 숫자 공간으로.
  • process(inst, key, value) — 실제 처리 수행(아래 "확정된 디스패치 모델" 절 참고). v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할.
  • retract(inst, key, value) — 이전 처리를 무르는/멈추는 함수(아래 절, base/lifecycle-pattern.md 참고). 모든 핸들러가 의미 있게 구현할 필요는 없음(예: 일반 프로퍼티 핸들러는 보통 no-op). retract 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 세션, 확정)Dispatch.process(아래 "확정된 디스패치 모델" 절)는 담당 핸들러 타입이 바뀔 때 이전 핸들러의 retract를 nil 체크 없이 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: Tween↔ 프로퍼티 교체) 실제로 담당이 바뀌는 순간 attempt to call a nil value로 바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가).

디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 isHandlable을 호출, 첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 열린 레지스트리). tbox의 TUnion 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치 반환, 실패 정보는 클로저로 지연 생성" 패턴을 구현해뒀음(.claude/initreq/tbox/ src/schema/union.luau:48-68) — 에러 메시지는 즉시 문자열로 만들지 말고 매치 실패 시에만 클로저 호출.

확정된 디스패치 모델: process(inst, k, v) / retract(inst, k, v)

사용자가 직접 준 구체적인 모델 — 이 문서의 이전 초안보다 우선함. 아래가 실제로 구현할 모양:

  • 모든 핸들러는 대상 Instance를 직접, 항상 받는다. quad는 "인스턴스를 생성하고 그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을 그 인스턴스에 적용하도록 돕는 역할에 가깝다. 그래서 핸들러가 "나중에 생길 대상"을 비동기로 기다릴 필요 자체가 없음(아래 Ref 절 참고 — Ref는 다른 이유로 존재).

    • 보강(2026-08-04): inst가 항상 살아있는 엔진 객체(Roblox Instance)일 필요는 없음 — 특정 백엔드에서 실제 엔진 객체 생성/바인딩 비용이 비싸면 (예: 웹 DOM) 중간 표현으로 평범한 테이블을 만들고 나중에 그 테이블을 렌더링하는 것도 가능. 이건 core(base)가 신경 쓸 일이 아니라 각 최종 엔드포인트 백엔드(quad-roblox/quad-web 등)가 알아서 결정할 문제 — base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량.
  • process(inst, k, v) — 우선순위 순으로 등록된 핸들러를 스캔, isHandlable(inst,k,v)를 만족하는 최상위 핸들러가 실제 처리를 담당. 이 "스캔+실행" 오케스트레이터는 Dispatch.process로, 순수 스캔 부분은 Dispatch.getHandler로 이름이 공식화됨(아래 None 센티널 절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상 그냥 process로 계속 씀.

  • 예시: Tween의 store-bind 핸들러는 k는 무엇이든 받고 v가 Store인 경우를 잡아내는, 우선순위가 매우 높은 핸들러v가 store이면 그 값을 처리(구독)함. 이 핸들러 안에서:

    1. 지금 이 처리가 실행되어도 되는지 라이프타임(Connected)을 확인 — 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 그 시점엔 그냥 Connected를 보고 무시(no-op).
    2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(realv)을 계산.
    3. 재귀 호출 전에 먼저 Dispatch.retractUnder(inst, k, self, realv)를 불러 자기 밑에 위임돼 있던 걸 정리한 뒤, realv를 들고 Dispatch.process(inst, k, realv)를 재귀 호출(정확한 메커니즘은 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터 이름 공식화는 아래 None 센티널 절 참고, 2026-08-07 여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 이 문서 이전 초안의 결론과 일치. realv가 store가 아니라면 자연히 Tween의 store-bind 핸들러 isHandlable을 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음.
  • retract(inst, k, v) (이전 초안의 "cleanup", 이름 변경 근거는 base/lifecycle-pattern.md 참고) — 이전 처리를 무르는/멈추는 함수. 오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재 — 인스턴스/바인드 전체가 Destroy될 때는 retract가 호출되지 않음(base/ lifecycle-pattern.md의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고).

    • 일반 프로퍼티는 애초에 "unset" 개념이 없음(nil로 셋하는 것도 그냥 셋 동작) — 그래서 프로퍼티 핸들러는 보통 retract가 필요 없음.
    • retract가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는 핸들러 타입 자체가 사이클마다 바뀌는 경우" (2026-08-07 여덟 번째 세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행 중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게 retract의 일. Attribute는 여기 해당 안 함 — UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 담당, 추가/제거를 전부 process 자신이 처리)이라 핸들러 교체 자체가 안 일어남 — base/attribute-plan.md. [정정, 2026-08-08 세 번째 세션] Tag는 더 이상 여기 해당하지 않음 — array-part 값 객체로 재설계되며(base/tag-plan.md, 구 모델은 archive/ tag-hash-key-model-reversed.md) Tag(...)nil 사이에서 핸들러 타입 자체가 바뀌므로 retract가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는 diff는 process가 담당.
    • store bind가 새 값으로 넘어갈 때 이전 핸들러의 retract를 호출해주면 됨 — 정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고(재귀 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 2026-08-08 세 번째 세션에 구체화됨, 여기 한 줄 설명은 그 요약).
  • 핸들러 내부 상태 저장: retract가 "이전에 생성한 것"(예: 실행 중이던 Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — inst를 키로 하는 weak-keyed 테이블에 k별로 저장(예: 생성된 Tween을 담아뒀다가 나중에 멈추거나 끝냄). [정정, 2026-08-08 세션] base.perInstanceState(inst)라는 이름/모양은 폐기 — base/relate-plan.mdRelate 프리미티브로 구체화됨. 각 핸들러 모듈이 자기 톱레벨에 local relate = Relate()를 하나 두고 relate:SetStrong(inst, k, tween)/relate:GetStrong(inst, k)로 저장/조회 — "모든 핸들러가 WeakMap을 재발명하지 않고 공유 유틸을 쓴다"는 원래 취지는 그대로, Relate가 그 공유 유틸의 정식 인터페이스. base/lifecycle-pattern.mdbindLifetime/canExecute도 같은 Relate를 내부적으로 씀(용도가 다르니 별도 Relate() 인스턴스). 왜 GC-안전한가(2026-08-07 여섯 번째 세션, 명시화): 구조가 "inst로 weak-keyed된 바깥 릴레이션 안에 k별 안쪽 릴레이션이 중첩된" 모양이라, inst가 죽어 weak table 엔트리가 통째로 사라지는 순간 그 안에 중첩된 k별 Tween 인스턴스 릴레이션도 같이 GC됨 — 별도 cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 자동으로 같이 죽는 것까지 포함, research/tween-plan.md 참고).

  • 다른 값 변경을 추적하는 것도 process 함수의 정상 범위: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 갱신해야 함 — retract 시점엔 그 추적(구독)만 풀면 됨.

  • 일반적인 무한루프 방어(사이클 감지 등)는 하지 않기로 확정(2026-08-04, 로드맵 인수인계 라운드): 우선순위 스캔+재귀 process 구조 자체는 핸들러가 규율을 안 지키면(예: 값을 좁히지/변형하지 않고 같은 값을 그대로 다시 process에 넘김) 무한루프에 빠질 수 있음 — 하지만 이건 base가 방어 로직을 둬야 할 문제가 아니라 오작동하는 handler/provider(quad-roblox 등) 쪽 버그로 간주 — 사용자 확정("입력된 값이 다시 입력되면 무한루프 빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나 프로바이더가 잘못 짠 코드일테니까"). Tween의 store-bind 재귀 케이스(위 78-79행)처럼 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 아니라고 판단해 별도 가드 없이 진행.

  • props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션). Luau 테이블을 pairs/제네릭 for로 순회하면 실제로 배열 파트가 해시 파트보다 먼저 나옴(for i, v in {a=1, 2, b=3} do print(i,v) end1 2, a 1, b 3 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화한다 — 숫자 키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리. 이유: (1) 다른 백엔드(quad-web 등)가 병합된 props를 Lua 테이블이 아닌 다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 참에 순서까지 명시적으로 고정하는 게 거의 공짜. 결과적으로 배열 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 먼저 처리된다는 게 base 자체의 보장이 됨 — 아래 "Ref 일반화" 절 뒤에 이어지는 "PreRef" 절이 이 보장 위에서 성립. M0 스파이크에서 실제 Luau로 이 순회 동작 자체를 검증할 것(지금까지 추론/관찰만으로 확정된 항목 — research/pre-implementation-audit.md가 짚은 "실제 Luau로 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸).

None 센티널 — Tween store-bind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션)

modifier-plan.md "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/Peek)에서 쓰는 None 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 층위. 결론: 새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 Tween store-bind 핸들러(65-79행)와 완전히 같은 모양의 핸들러 하나 추가.

NoneHandler.priority = <매우 높음>
NoneHandler.isHandlable(inst, k, v) = (v == None)
NoneHandler.process(inst, k, v) = process(inst, k, nil)  -- 재귀 재호출
  • 매치 predicate는 isHandlablecanExecute가 아님. 둘은 완전히 다른 개념이라 혼동하지 말 것: isHandlable(k,v)는 KV 매치 predicate(핸들러 계약 4종 중 하나, 이 절에서 다루는 것), canExecute는 인자로 받은 특정 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 라이프타임 게이트(base/lifecycle-pattern.md "생명 바인드 유틸" 절) — KV 매치와 무관. NoneHandler는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 None을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" 절 "호이스팅의 실제 구현" 참고). 배열 파트의 None은 "빈 슬롯" 표시일 뿐 처리할 핸들러 자체가 없으므로, Dispatch.drive의 두 패스 루프 자신이 NoneHandler/Dispatch.process를 거치지 않고 바로 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 경로가 다르다는 점에 유의. NoneHandler.isHandlablev == None(센티널 자체)을 잡는 것이지 v == nil이 아님 — 진짜 nil은 애초에 테이블 순회로 나올 수 없다는 게 이 문제의 출발점이었으므로, 매치 대상은 항상 None 마커. Dispatch.process(inst, k, nil)로 재귀 호출하는 순간 None은 더 이상 존재하지 않고 진짜 nil이 되므로, 다음 우선순위 스캔은 자연히 키 k를 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — Tween의 store-bind 핸들러가 realv를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음.
  • Dispatch.process/Handler.process 이름 겹침 — 소유자 네임스페이싱으로 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속). 원래 "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 로직" 둘 다 그냥 process라고 불러서 이름이 겹쳤음 — 이제 두 계층을 명시적으로 분리:
    • Dispatch.getHandler(inst,k,v): Handler? — 순수 스캔(handler.isHandlable(inst,k,v)+ priority), 부작용 없음.
    • Dispatch.process(inst,k,v) — 오케스트레이터: getHandler 호출 → 이전에 이 키를 담당하던 핸들러와 다르면 이전 핸들러의 retract 호출 → 새로 매치된 핸들러의 .process 호출. 재귀 재디스패치(Tween/일반 store-bind/NoneHandler)는 전부 이 Dispatch.process를 다시 부르는 것 — 원래 있던 재귀 관례 그대로, 새로 바뀐 것 없음.
    • Dispatch.addHandler(handler: Handler) — 핸들러를 우선순위 레지스트리에 등록. Dispatch.process/getHandler와 마찬가지로 base엔 인터페이스만 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ UICornerHandler/TagHandler/AttributeHandler 등)은 팩토리가 BaseModule을 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 같은 패턴, 새 메커니즘 아님).
    • Handler 자신의 필드는 계속 process/retract(이미 확정된 이름, question.md에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 소유자를 명시(Dispatch.process vs handler.process)하는 것으로 해소.
    • base 드라이버 루프 자신의 이름은 Dispatch.drive(inst, flattened)로 확정 — 이미 위 "props 순회 순서" 절이 이걸 비공식적으로 "base 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(apply는 "Dispatch를 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). inst와 flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 (k,v)Dispatch.process를 호출하는 게 이 함수의 본체.
  • v=nil이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, None 자신은 "리셋"이 아님 — 일반 프로퍼티는 "nil로 셋하는 것도 그냥 셋 동작"이라 사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔 자식 Instance를 실제로 지우는 것까지 포함 — 구체 예시는 base/ui-shorthand-plan.md/base/tag-plan.md/base/attribute-plan.md. None은 **"이 조합 단계에서 나는 이 필드를 세팅 안 한다"**는 뜻이고, 그걸 받은 실제 핸들러가 무엇을 할지는 각자 몫. 개별 프로퍼티/이벤트/UI shorthand 핸들러의 process 시그니처는 안 바뀜 — 이들은 원래도 v가 State 계산 결과로 nil이 되는 경우를 처리할 수 있어야 했으므로(일반 반응형 케이스), None은 그 기존 경로에 도달하는 방법 하나가 늘어난 것뿐. 구현 디테일 캐비엇: None→nil이 Roblox의 nil을 허용 안 하는 타입 프로퍼티(Color3/number 등)에 도달하면 inst[k] = nil은 런타임 에러 — PropertyHandler 자신이 v == nil이면 셋을 건너뛰는 방어를 갖고 있어야 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸).
  • retract와는 무관retract는 "같은 키를 다른 핸들러 타입이 넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지 "vnil이 됨"과는 다른 문제. None → nil 재디스패치는 항상 Dispatch.process 경로로만 흐름 — NoneHandler 자신도 retract가 딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로).
  • [해소됨, 2026-08-08 세 번째 세션] "이 키를 지금 누가 담당 중인가" bookkeeping — pre-implementation-audit.md 우선순위1 "이전에 실제로 매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch 체인" 절의 chains/Dispatch.retractUnder로 구체화됨 — NoneHandler의 재귀 재호출도 이 메커니즘 위에서 동일하게 동작(None으로 유지되는 매 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요).

Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션)

Dispatch.process/getHandler/addHandler/driveSource/Ref/Store/ Modifier처럼 생성자가 있는 프리미티브(예: Dispatch()로 인스턴스를 여러 개 만들 수 있는 것)로 바꿔야 하는지 검토 후 기각, 지금 형태(모듈 require로 바로 닿는 flat 탑레벨 함수) 유지로 확정:

  • 재귀 재-dispatch가 요구하는 필연 — Tween/NoneHandler/Dispatch/ StoreBind.luau 전부 자기 process 안에서 다시 Dispatch.process(inst,k, realv)를 호출함(위 "확정된 디스패치 모델"/"None 센티널" 절). 이게 성립하려면 Dispatch가 canExecute/bindLifetime(base/ lifecycle-pattern.md)과 똑같이 require 한 번으로 바로 닿는 안정된 전역이어야 함 — 인스턴스화 가능한 프리미티브로 만들면 모든 Handler 등록/호출 경로에 Dispatch 핸들을 인자로 계속 실어날라야 하는 스레딩 비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐.
  • 순환참조로 보이는 건 착시 — 실제로는 단방향. "Handler"라는 말이 두 가지를 가리켜서 헷갈릴 수 있음: (a) Handler.luau타입 계약 (isHandlable/priority/process/retract 시그니처만 있는 순수 leaf, Dispatch를 몰라도 됨) vs (b) StoreBind.luau/Tween.luau처럼 그 계약을 구현하는 concrete 값 모듈(재귀호출 위해 Dispatch를 require함). 의존 방향은 항상 한쪽으로만 흐름 — Handler.luau(leaf) ← Dispatch/init.luau (addHandler(h: Handler)Handler 타입만 참조) ← StoreBind.luau/ Tween.luau(재귀호출 위해 Dispatch를 참조). Handler.luau 자신이 Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김. 런타임에서도 마찬가지 — 어떤 handler의 process든 실제로 호출되는 시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가 완전히 끝나있어 부트스트랩 문제도 없음.
  • quad-base 자신의 기본 핸들러도 같은 레지스트리를 씀NoneHandler, Dispatch/StoreBind.luau("범용, 엔진 무관")뿐 아니라, children 배열 숫자 슬롯에 Ref/Observer/PreRef를 직접 놓는 leaf 값을 매칭하는 Handler도 여기 속함(instany로 취급, 엔진 특정 API 불필요 — .claude/question.md가 2026-08-08 세션에 "quad-base/quad-roblox 중 어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base, Dispatch/Leaf.luau, Dispatch.addHandler로 등록). quad-roblox의 Property/Event/Tween 핸들러도 같은 Dispatch.addHandler 레지스트리에 등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 전부 하나의 우선순위 스캔을 공유.
  • 모듈 재생성(New())과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 풀림. v1처럼 require를 감싸 Init(QuadId?)로 격리 인스턴스를 만드는 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 BaseModule을 뮤테이션해서 주입"(RobloxFactory(BaseModule)) 패턴을 그대로 따름 — Dispatch의 handler 레지스트리도 BaseModule 테이블에 딸린 state 중 하나일 뿐이라, _initializedBy 마커에 대해 이미 확정된 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "New()가 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요"). New()가 실제로 생기면 그 시점에 BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 딸려가고, 호출부는 module.Dispatch.process(...)처럼 그 인스턴스 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음.

Dispatch 체인 — 재귀 재-dispatch의 retract 전파, Dispatch.retractUnder (2026-08-08 세 번째 세션)

문제: Tween/NoneHandler/StoreBind처럼 자기 process 안에서 Dispatch.process(inst,k,realv)를 다시 부르는 래핑 핸들러가 있으면, 같은 (inst,k)에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간 깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 같은 슬롯을 두고 서로 덮어씀. 구체적으로: A의 재귀 진입 시점에 슬롯을 A→B로 갱신해두면, A가 스스로 다시 값을 재계산해 재-dispatch할 때(예: store 값이 또 바뀜) 그 슬롯엔 이미 B가 적혀있어 "A로 바뀌었다"고 오판해 A 자신을 엉뚱하게 retract하거나, 반대로 A가 자길 스스로 retract하는 오작동이 남 — 처음 검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 이 이유로 기각됨(당시 대화에서 직접 반례로 확인).

해법 — Dispatch가 (inst,k)별 핸들러 체인(순서 있는 배열)을 소유:

-- Dispatch/init.luau
local chains = Relate()  -- {[inst(weak)] = {[k] = {handler, handler, ...}(strong, 순서 있는 배열)}}

function Dispatch.process(inst, k, v)
    local h = Dispatch.getHandler(inst, k, v)
    if h then
        local list = chains:GetStrong(inst, k) or {}
        table.insert(list, h)           -- 항상 꼬리에 추가
        chains:SetStrong(inst, k, list)
        h.process(inst, k, v)
    end
end

function Dispatch.retractUnder(inst, k, keep, v)
    local list = chains:GetStrong(inst, k)
    if not list then return end
    local cutoff = 0
    if keep then
        for i, h in list do if h == keep then cutoff = i break end end
    end
    for i = #list, cutoff + 1, -1 do
        list[i].retract(inst, k, i == cutoff + 1 and v or nil)
        list[i] = nil
    end
end
  • 재귀/래핑 핸들러는 재-dispatch 전에 반드시 Dispatch.retractUnder(inst, k, self, newV)를 먼저 부른 뒤 Dispatch.process(inst, k, newV)를 부름 — "나 밑에 있던 걸 전부 정리하고 새로 위임". keep(자기 자신) 바로 다음 항목만 실제 newV를 받고, 그보다 더 안쪽(다단 체인이 있을 경우)은 nil을 받음 — 더 안쪽 항목엔 "구체적으로 뭐로 대체됐는지" 정보가 없고 "완전히 사라진다"는 것만 사실이라서.
  • 개별 핸들러의 retract는 더 이상 자기 위임 대상을 수동으로 안 쫓아가도 됨retractUnder가 꼬리부터 keep 앞까지 한 번의 루프로 체인 전체를 순서대로 정리해주므로, A→B→C처럼 몇 단계든 각 핸들러는 자기 자신의 자원만 정리하면 자동으로 전파됨(질문 제기됐던 "다단 체인에서 안쪽까지 retract가 안 간다" 문제가 이걸로 해소 — retractUnder의 루프 자체가 체인 전체를 훑으므로 각 핸들러가 수동으로 cascade할 필요가 원천적으로 없음).
  • 구멍 걱정 없음 — 이 배열은 항상 꼬리에서만 추가/삭제되는 스택 모양이라(retractUnder가 항상 꼬리부터 연속으로 지움), "촘촘하지 않은 정수 키는 순회 순서가 깨진다"는 문제(위 "PreRef" 절의 None 소진 이슈)가 애초에 발생할 구조가 아님.
  • retract는 여전히 (inst,k,v) 3-인자 — 드롭하자는 제안이 대화 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 있어서, base/tag-plan.md 참고). 다만 v가 실제로 필요한지는 핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만" 불리므로 v를 안 봐도 항상 전체 삭제가 맞음(무조건), Tween 같은 경우는 자기 Relate 저장분만 보고 Cancel하면 되니 역시 v를 꼭 안 봐도 됨 — v는 "계약상 항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것.
  • 순환은 UB, 방어 로직 없음 — Handler 간 순환 참조(A가 B를 부르고 B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그 키에서 호출됨을 전제) — 값에 별도 플래그를 심어 의도적으로 순환을 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 같은 결로 UB 취급.
  • 부수 효과 — 미래 재바인드/quad-debug에 유리: 이 체인이 Dispatch에 중앙화돼 있으므로, research/existing-instance-bind-plan.md가 다룰 미래의 재바인드는 Dispatch.retractUnder(inst, k, nil, newV); Dispatch.process(inst, k, newV) 두 줄로 "이 키의 체인을 통째로 갈아 끼우기"가 자연스럽게 됨(각 래핑 핸들러가 자기 전용 Relate에 위임 대상을 비공개로 숨겨두는 대안 설계는 이게 안 됨 — 대화 중 검토 후 기각). research/debug-tooling-plan.md의 "무엇이 무엇에 연결됐는가" 그래프도 이 chains 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 새로 설계할 필요 없음.

Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가

사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음."

확정: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 다른 핸들러와 동일한 isHandlable/priority/process/retract 계약을 따르되, 자신의 process가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 value)로 Dispatch.process(inst,k,realv)를 재귀 호출"하는 식으로 구현됨. 이러면 store 값 자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) 상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결.

"값이 바뀔 때마다"의 실제 구독 메커니즘 = state:Observer(fn) 재사용으로 확정(2026-08-08 세션). 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 서술했는데(새 프리미티브를 발명하는 것처럼 읽힐 수 있었음), 실제로는 아래 "state:Observer(fn)" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음:

-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state)
local observer = state:Observer(function()
    Dispatch.retractUnder(inst, k, StoreBind, state:Get())  -- 나 밑에 있던 거 정리
    Dispatch.process(inst, k, state:Get())                          -- 새로 위임(체인에 push)
end)
observer:Subscribe()
relate:SetStrong(inst, k, observer)  -- retract에서 :Unsubscribe() 하려면 들고 있어야 함
  • children-array leaf 부착(Frame { observer })이 아니라 :Subscribe()/ :Unsubscribe() 경로를 씀 — 이 Observer는 핸들러 내부 배관이라 사용자가 보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer 핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 :Subscribe()가 유일한 선택).
  • retract가 할 일은 observer:Unsubscribe() 호출뿐 — 위임 대상까지 수동으로 안 쫓아가도 됨. Dispatch.retractUnder가 자기 밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 핸들러의 retract는 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 계약만 구현).
  • 핸들러가 직접 canExecute/liveness를 재구현할 필요 없음 — Observer가 이미 자기 Subscribed 상태로 게이팅됨(아래 base/lifecycle-pattern.mdcanExecute(inst, value) 절 참고, Observer/Effect는 그 함수 안에서 특별 취급됨).
  • Observer가 "등록 즉시 1회 실행"이므로 최초 적용과 이후 재실행이 같은 코드 경로로 자동 통일됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로).
  • relatebase/relate-plan.mdRelate 인스턴스 — 이 핸들러 모듈 톱레벨에 local relate = Relate()로 하나 두고 재사용.

Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 retract 핸들러가 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 자연스럽게 맞음 — base/slot-plan.md 참고.

Store가 Store를 저장 가능한가

사용자 원 메모: "슬롯을 스토어처럼 생각 가능하다면 이건 가능하다고 봐야하는가? 아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나 ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로."

2026-08-04 6차 확정: 그런 경우는 없다고 본다. 위에 적힌 "재실행 래핑으로 기계적으로는 커버 가능하다"는 제안은 메커니즘상 틀리지 않지만, 실제 설계 의도와 안 맞음 — Store는 Source에 준하는 존재로 모든 반응형 값의 "시작점" 역할만 함. 시작점은 다른 변화하는 무언가에 연결되는 것을 제공하고자 하지 않음(= Store가 다른 Store/State를 값으로 담아 자동으로 따라가게 하는 용도로 쓰지 않음). Store에서 값을 꺼내 State를 옵저빙하다가 콜백으로 다른 Store 값을 바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의 보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음.

Ref — 도입 확정, 단 용도는 재정의됨

중요한 정정: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(Tween 핸들러도 process(inst,k,v)처럼 항상 대상 Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, research/tween-plan.md도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:

  • v1의 Frame "id" {} + Store.GetObject(id) 식 id 매핑은 폐기 확정 (base/architecture.md 5번 항목) — "비현실적"이라는 게 이유.
  • 하지만 "라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게" 하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로 관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을 Parent로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가 하는 다양한 용도.
  • Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 달리 "원하는 객체 자체를 직접 얻어오는" 경로. 얻어진 뒤에 그 참조를 어디에 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖(사용자 자유).
  • 바인드 방법: children을 배열 아이템으로 넣듯 Ref(default)(또는 :Callback(fn)을 미리 걸어둔 Ref(default):Callback(fn)) 인스턴스 자체를 숫자 키 슬롯에 그대로 넣는 방식 — (v=Ref) 매치 핸들러가 이걸 처리함. 별도 CreatedRef 래퍼 함수는 없음(2026-08-07 아홉 번째 세션, 사용자 확인) — Ref/PreRef가 이미 Compose식 Type(default) 팩토리 생성자로 확정됐으므로("생성자 스타일 확정" 절), 그 결과를 그대로 children 배열에 놓는 것 자체가 바인드 관용구. 원래 "CreatedRef 같은 이름 미정의 래퍼"로 서술했던 것은 Ref가 아직 "instance 얻는 통로"로 좁게 정의됐던 시절(2026-08-04)의 잔재였고, 2026-08-06 Ref 일반화 이후 래퍼 자체가 불필요해졌는데 이름만 남아있던 것을 이번에 정리함. [정정, 2026-08-07 세 번째 세션] 정확한 순서 보장(자식 마운트 전/후, 프로퍼티보다 먼저)은 위치와 PreRef 타입으로 갈렸음 — 아래 "phase 옵션 폐기 → 위치로 표현, PreRef 신설" 절이 최신, 원래 있던 "옵션({phase=...})으로 두 타이밍을 고른다"/"특수 처리 없는 평범한 참가자" 서술은 archive/ref-phase-option-reversed.md로 옮김.
  • 왜 값이 아니라 콜백인가: quad는 React처럼 렌더 함수가 계속 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, useEffect 의존성 배열 같은 것도 없음. 즉 값이 채워지는 시점을 외부에서 알아낼 방법이 콜백(또는 폴링, 채택 안 함 — lifecycle-pattern.md에서 폴링 방식은 이미 기각된 패턴) 말고 없음. 값 자체를 나중에 다루고 싶으면 콜백 안에서 원하는 곳(외부 변수, self._button 같은 필드, Store 등)에 직접 대입해 캡쳐하면 됨 — component-composition-plan.md 31행 예제 참고. 즉 "값으로도 얻어진다"는 요구는 별도 API가 아니라 콜백이 이미 충족함.

Ref 일반화 — 엔진 instance 전용이 아니라 범용 값 박스 (2026-08-06 후속 세션)

결정: Ref는 "quad가 만든 instance를 얻는 통로"로 좁게 남지 않고, **아무 사용자 값이나 담을 수 있는 범용 "채워지길 기다리는 값 박스"**로 확장한다. 위 "코루틴 기반 대기 지원 여부는 미정"이었던 항목은 이걸로 해소됨(더 이상 열린 질문 아님).

  • object-ref/function-ref로 나누지 않음. React의 useRef가 DOM 노드든 임의의 사용자 값이든(함수 포함, ref.current?.()로 호출하는 imperative-handle 패턴 포함) 같은 API로 다루는 것과 동일한 선례 — 두 개념으로 쪼개면 사용자가 "이번엔 어느 쪽을 써야 하나" 매번 판단해야 해서 나쁨. 엔진 instance도 그냥 "사용자 값의 한 종류"일 뿐.
  • 구체 유스케이스: 자식 컴포넌트가 비싸고 온디맨드로만 필요한 계산 (예: 클릭 위치 기준 컨텍스트 메뉴를 그리기 위한 clip bounds 계산)을 부모에 노출하고 싶을 때, 매 변경마다 push하는 대신 부모가 필요할 때만 ref.Value?.()처럼 호출하는 함수를 Ref에 담아 넘기는 패턴 — React의 imperative handle과 동일한 이유(비싼 연산이라 온디맨드가 맞음, 값이 최신인지 아닌지도 애매해짐).
  • API 모양: .Value(읽기 전용 필드) + :Set(value) -> Ref<T>(쓰기) + :Callback(fn) -> Ref<T>(콜백 등록, 복수 허용) + :Wait(thread?) -> Ref<T> (coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 return 위에 바로 못 씀, 그래서 콜백도 같이 필요) 세 메소드로 확정(2026-08-07 여섯 번째 세션). :Set/:Callback/:Wait 전부 mutation 패턴이라 자기 자신 (Ref<T>)을 반환store.key:Set(value)류 "값을 바꾸는 연산엔 : 체이닝 허용" 원칙(base/store-semantics.md 190행)의 자연스러운 재적용. 이 self-반환 덕에 Luau의 if-표현식과 결합해 흔한 관용구를 한 줄로 쓸 수 있음(사용자 제시 예):
    local t = if ref.Value
      then ref.Value
      else ref:Wait().Value
    
    • 콜백은 이미 채워져 있으면 등록 즉시 그 값으로 1회 호출됨 — nil/미설정 상태여도 그 상태 그대로 호출. React의 useEffect가 매번 .current 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 필요함. :Wait()의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 None으로 소진 — 아래 구현 디테일의 2026-08-07 열 번째 세션 정정 참고, 단순 nil 처리는 아님).
    • :Wait(thread?)thread 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정): 생략(nil)하면 coroutine.running()으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 coroutine.yield()자기 자신을 정지시킴(값이 채워지면 재개). 명시적으로 다른 thread를 넘기면 그 thread를 대기자로 등록만 하고 정지 없이 즉시 self를 반환 — 코루틴 역학상 coroutine.yield()는 지금 실행 중인 코루틴만 정지시킬 수 있고, 어딘가 이미 정지해 있는 남의 thread를 여기서 대신 정지시킬 수는 없기 때문(그 thread는 이미 정지 상태). 이 표면의 용도: 사용자가 직접 관리하는 스케줄러가 이미 만들어 둔(어딘가 다른 지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — threadnil이면 yield, 있으면 yield 안 함.
    • 구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume payload 정정, 열 번째 세션에서 소진 방식 정정): 값이 새로 :Set()될 때, 같은 배열 하나를 for i, v in <배열> do ... end로 한 번만 순회하면서 type(v) == "thread":Wait()가 만든 대기자로 보고 coroutine.resume(v, self) (즉 값이 아니라 Ref 자기 자신을 resume 인자로 넘김 — 위 self-반환 관용구가 :Wait()의 yield 경로에서도 그대로 성립하게 하기 위해, coroutine.yield()의 리턴값이 곧 self가 되도록 정정. 세 번째 세션 원안은 value를 넘기는 것으로 적혀 있었으나 이러면 ref:Wait().Value가 안 풀려서 이번 세션에 정정) 후 [i] = None(nil이 아님 — 아래 "왜 nil이 아니라 None인가" 참고), 아니면 일반 콜백 함수로 보고 그냥 v(value)(콜백은 여전히 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능(type(v) == "thread" → 대기자, type(v) == "function" → 콜백, 그 외/None → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 table.insert로 끝.
    • nil이 아니라 None인가(2026-08-07 열 번째 세션, 사용자가 실제 Luau REPL로 반례 제시 후 정정) — 이전 서술("성긴 배열이어도 일반화 for가 계속 잘 순회하므로 압축 불필요")은 절반만 맞았음. 대기자/콜백 자체는 순서가 안 중요해서(어느 게 먼저 fire되든 상관없이 전부 fire되기만 하면 됨) "잘 순회함"까지는 맞았지만, 두 가지를 놓쳤음: (1) 키가 촘촘한 저범위 정수(1,2,3,...)에서 벗어나 듬성듬성해지면(nil로 지운 슬롯도 포함) Luau/Lua 테이블이 그 키들을 해시 파트로 취급해 순회 순서가 해시 버킷 순서가 되어버림(사용자가 {[1]=1,[2222]=2222,[211]=211,...}류 REPL 실측으로 확인 — 대기자/콜백 리스트 자체는 이 순서 소실이 문제 안 되지만, 순서가 실제로 중요한 다른 배열(PreRef pre-pass 등)엔 치명적). (2) table.insert가 내부적으로 쓰는 #t(length 연산자)는 Lua 명세상 구멍이 있는 테이블에서 정의되지 않은 동작이라, 다음 콜백/대기자 등록이 엉뚱한 인덱스에 들어가 기존 항목을 덮어쓸 위험이 있음 — 이건 대기자/콜백 리스트에도 실제로 해당하는 진짜 버그. Nonenil이 아닌 실재하는 값이라 그 슬롯이 "차 있다"는 사실 자체는 안 바뀌므로 두 문제 다 피함 — 소진된 슬롯도 여전히 non-nil 값을 갖고 있어 테이블이 "구멍 없는 시퀀스"라는 불변식이 깨지지 않음.
    • 주의(문서화 대상, 방어 로직 없음): 이미 죽은(완료/에러난) thread를 :Wait(thread)에 넘기면 나중에 coroutine.resume이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 둠.
  • 제네릭 시그니처(2026-08-07 확정): Ref<T>(T) -> Ref<T> — 단일 타입 파라미터. React useRef<T, U=T>(U): T|U류 "초기값 타입과 최종 타입을 분리"하는 2파라미터 설계도 검토했으나(예: Ref<<HTMLDivElement>>(null)HTMLDivElement|null), Luau 솔버로는 명시된 타입 파라미터 하나와 인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게 풀리지 않고 미해소 제네릭 변수가 결과 타입에 남는 것으로 확인(사용자가 직접 Luau 플레이그라운드류로 확인) — Source<T> satisfies State<T>State<Modifier> 차단 검증 항목(research/pre-implementation-audit.md) 에서 이미 반복 확인된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다"는 패턴과 같은 결. 단일 파라미터로 단순화하면 이 위험 자체가 없음 — 대신 초기값만으로 좁은 타입이 추론되는 문제(Ref(nil)Ref<nil>로 좁혀짐)는 Ref<<Obj?>>(nil)처럼 명시적 제네릭 적용(f<<T>>(...) 패턴, .claude/initreq/tbox/CLAUDE.md:40-41 선례)으로 타입을 넓혀 풀면 됨 — React useRef<HTMLDivElement>(null)도 명시적 타입 인자 없이는 같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는 트레이드오프 아님.
  • children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계: 이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 Ref 인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든 instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 기반 + PreRef 타입으로 표현됨 — 아래 "phase 옵션 폐기 → 위치로 표현, PreRef 신설" 절이 최신.
  • 해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정. React에서도 자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스 호버/무브 시 current 확인 후 라벨 위치를 결정하는 라벨 컨테이너 하나를 두고 라벨 내용만 스왑해가며 Ref를 재사용하는 패턴. 이런 고급 패턴은 조심할 게 많지만 그건 라이브러리가 아니라 사용자가 신경 쓸 몫. 따라서 콜백은 "발화 후 소진"이 아니라 매 :Set()마다 다시 불림 — 소진되는 건 :Wait()가 만드는 개별 대기자(coroutine 재개는 본질적으로 1회성)뿐, 콜백 리스트 자체는 유지됨.
  • ⚠️ Ref는 의도적으로 lazy가 아니고 :Compute 파생을 지원하지 않음 — State와의 이 차이가 중요함. (예전엔 Store가 Ref와 비슷한 것도 겸해서 지원한 적이 있었는데, State의 lazy 재계산 모델과 Ref의 즉시 get/set 모델이 섞여서 좋지 않았음 — 그 경험에서 나온 의도적 분리.) Ref는 그냥 "지금 뭐가 들어있나/누가 채워주길 기다리나"만 다루는 즉시 값 박스이고, 파생값이 필요하면 Store/State(:With+:Compute)를 쓸 것 — 둘을 섞으려 하지 말 것.
  • 용어 정리 합류 대상: Ref의 정의 자체가 "instance를 얻는 것"에서 "범용 값 박스"로 넓어졌으므로, 진행 중인 용어 정리(question.md 1번) 때 이름이 여전히 맞는지 같이 재검토할 것.

phase 옵션 폐기 → 위치로 표현, PreRef 신설 (2026-08-07 세 번째

세션 — 이 절이 당시 쓰던 CreatedRef(fn, ...) 래퍼 이름 자체도 이후 아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고)

children 배열에 놓는 Ref에 {phase="created"|"mounted"} 옵션으로 두 타이밍을 고르게 하던 것 자체를 없앤다. 위 "확정된 디스패치 모델" 절에 새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 일반 Ref 다른 children보다 앞/뒤 어디에 놓느냐가 이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어가므로, "마지막에 놓기"만으로 "모든 자식 마운트 후" 의미가 공짜로 나옴. 별도 옵션 문법을 유지할 이유가 없어짐. (아래 PreRef는 이 위치-의존 규칙의 예외 — 위치 영향을 아예 안 받고 호이스팅됨, 해당 절 참고.)

단, "프로퍼티/이벤트 세팅보다도 먼저"는 위치만으론 못 푼다. 배열 파트가 해시 파트보다 항상 먼저 처리된다는 보장은 그 인스턴스의 최초 props 테이블에 리터럴로 존재하는 항목에 한정됨 — Modifier를 거쳐 flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store를 거쳐 나중에 도착하는 값은 애초에 이 최초 스캔 자체를 벗어난 시점(process/retract 재귀 경로)에 도착하므로 이 보장 밖. 그런데 "프로퍼티보다 먼저 채워져야 한다"가 실제로 필요한 이유가 있음 — quad-roblox 이벤트는 self(Instance)를 안 주기로 확정했으니(아래 절) self 접근은 Ref로 해야 하는데, Roblox 이벤트 중 일부(ChildAdded/DescendantAdded/Changed류)는 유저 인터랙션을 기다리지 않고 setup 도중 프로퍼티 대입/Parent 세팅 자체의 부작용으로 동기적으로 발화할 수 있음 — 이때 이벤트 핸들러가 아직 안 채워진 self-ref를 읽으면 터짐.

해결: 이 케이스만 별도 타입 PreRef로 분리.

  • 구현은 Ref 그대로 재사용(같은 .Value/:Set()/:Callback()/ :Wait() API) — 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. 소스 파일은 분리(2026-08-07 여섯 번째 세션): Ref는 이제 그 자체로 충분히 완결된 프리미티브고 PreRef도 "children 배열 전용, 위치 무관 호이스팅"이라는 특이한 제약을 가진 별개 프리미티브라, 기존 프리미티브당 1파일 컨벤션(modifier-plan.md/slot-plan.md류, Blocker/Effect를 같은 이유로 분리한 2026-08-07 네 번째 세션과 같은 판단)을 따라 Ref.luau/PreRef.luau 두 파일로 쪼갬 — 런타임 로직은 여전히 공유 (PreRef.luauRef.luau를 그대로 불러다 브랜드 태그만 얹음), 파일 분리는 순수 조직 문제라 위 재사용 결정과 상충 없음. base/architecture.md 소스트리에 반영 완료.
  • 오직 children 배열의 리터럴 아이템으로만 놓을 수 있다Modifier 필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.
    • Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로 존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가 Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이 인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 — 허용해도 얻는 유스케이스가 없는 오버엔지니어링.
    • Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 없음 — State<Modifier>를 UB로 보고 타입으로 막기로 한 것과 정확히 같은 원칙의 재적용.
  • PreRef는 배열 안 위치의 영향을 안 받는다 — 호이스팅. 일반 Ref와 달리, 같은 인스턴스의 배열 파트 안에서 다른 children/Ref보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 마운트되면서 그 형제가 부모에 Parent될 때 부모의 ChildAdded류가 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 전에 별도의 작은 pre-pass로 배열 파트를 훑어 PreRef 항목만 먼저 전부 fire하고, 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 패스로 처리하면 됨 — 이 pre-pass는 오직 PreRef 타입만 골라내므로 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 앞에 얹히는 것.
    • 복수 PreRef 간 순서(2026-08-07 아홉 번째 세션, 사용자 확인) — 새 규칙 불필요, 배열 index 순서 그대로. 같은 인스턴스에 PreRef가 여럿 있으면, 이 pre-pass는 위 "props 순회 순서" 절이 이미 확정해둔 "배열 파트는 index 순서대로" 계약을 그대로 재사용해 리터럴 순서대로 fire하면 됨 — 서로 다른 우선순위/순서 개념을 별도로 만들 필요 없음 (호이스팅은 "PreRef 전체 대 나머지"에만 적용되는 규칙이지, "PreRef끼리" 에는 적용될 게 없음 — PreRef끼리는 그냥 평범한 배열 순회).
    • 호이스팅의 실제 구현 = "물리적 재배치"가 아니라 "완전히 별도의 선행 스캔"(2026-08-07 아홉 번째 세션 후속, 사용자 질문에 답변). Dispatch.drive(inst, flattened)는 같은 flattened 배열을 두 번 순회한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 isPreRef(v)인 슬롯을 찾아 그 자리에서 fire하고 즉시 **flattened[i] = None**으로 소진(nil이 아님 — 위 "왜 nil이 아니라 None인가" 절과 같은 이유, 2026-08-07 열 번째 세션 정정: nil로 지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 보장이라 치명적). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 같은 테이블을 다시 순회 — 이때 None으로 소진된 슬롯은 정상 Dispatch.process/NoneHandler 경로를 안 타고 두 패스 루프 자신이 직접 건너뜀(if v == None then continue end, 배열 파트 전용 특수 취급). 해시 파트의 None(Modifier 필드 명시적 지우기)과는 의미가 다름 — 해시 파트 None은 "이 프로퍼티 핸들러야, 방금 지워진 걸 알고 알아서 처리해"라는 의미 있는 재디스패치(NoneHandler 경유)지만, 배열 파트 None은 그냥 "여기 원래 아무것도 없었던 것과 같다"는 순수 빈 슬롯 표시라 처리할 핸들러 자체가 없음 — 재디스패치 없이 루프가 직접 스킵하는 게 맞음. "호이스팅"은 PreRef를 배열의 맨 앞으로 물리적으로 옮기는 게 아니라, PreRef 전용 선행 루프가 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻 — 그래서 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 fire됨. PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성 요건인 이유: 아래 "동적 경로 가드" Handler가 (v=PreRef)를 매치하면 무조건 error를 던지므로, pre-pass가 슬롯을 안 지우면 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 Dispatch.process로 다시 넘기게 되고, 그러면 이 가드 Handler가 엉뚱하게 매치되어 정상적인 PreRef 사용에도 에러가 터짐 — 소진은 이 오탐을 막기 위해 반드시 필요.
    • M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션): 위 "props 순회 순서" 절은 {a=1, 2, b=3}구멍 없는 테이블에서 배열 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 번째 세션). 같은 세션에서 사용자가 직접 {[1]=1,[2222]=2222, [211]=211,...}키가 듬성듬성한(sparse) 테이블을 REPL로 실측해, 그런 테이블은 순회 순서가 index 오름차순이 전혀 아님(해시 버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) None으로 소진해 테이블을 "구멍 없이 촘촘한" 상태로 계속 유지하는 전략으로 이 위험을 원천 회피함(검증 불필요, 애초에 구멍을 안 만드므로). 여전히 M0에서 검증해야 하는 건 다른 케이스: props.Modifier/ props.Ref를 caller가 안 넘겨 생기는 리터럴 nil-hole({nil, ref, child}, 위 "지금 할 일" 우선순위1 항목)은 caller가 직접 쓰는 raw Lua 리터럴이라 프레임워크가 None으로 대신 못 채워줌 — 이번 REPL 실측으로 그 케이스의 실제 위험도가 이전 서술("뒤 항목까지 무시될 수 있음", 국소적 피해로 서술돼 있었음)보다 훨씬 큼이 드러남: 구멍이 하나만 생겨도 테이블 전체가 해시 파트 취급으로 넘어가 그 인스턴스의 배열 파트 전체가 순서 보장을 잃을 수 있음. M0 스파이크에서 반드시 실측하고, 심각하면 "이런 nil-hole은 raw 리터럴로 하지 말고 항상 props.Modifier or Modifier()처럼 non-nil을 보장하라"는 컨벤션 문서화까지 검토할 것.
    • pre-pass는 어디 사는가 — Dispatch.drive(inst, flattened) 자신, 새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정). Dispatch.drive가 이미 (inst, flattened)를 받아 배열→해시 두 패스를 도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 충분 — Handler.process와 이름이 겹치는 새 Dispatch.process(inst, flatten, prerefs)류 함수를 따로 만들 필요가 없음(그 이름은 이미 다른 뜻으로 쓰이는 Dispatch.process(inst,k,v) 오케스트레이터와 겹쳐서 안 좋음). flatten(nonFlatten) -> flatten 함수 자체에 얹는 방안은 검토 후 기각 — flatten은 Modifier 값을 합치는 순수 변환(현재 inst를 안 받음, research/existing-instance-bind-plan.md가 다루는 "이미 마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 해야 하는가" 질문이 실제로 열려있어 flatten이 한 인스턴스 생애주기 동안 여러 번 재호출될 가능성이 있음 — 여기에 PreRef fire를 얹으면 재바인드마다 PreRef가 또 fire되어 "이 인스턴스 하나의 construction 훅"이라는 PreRef의 정의 자체가 깨짐. Dispatch.drive는 최초 마운트 시 정확히 한 번만 불리는 게 이미 전제라 이 위험이 없음.
    • 동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러 (2026-08-07 아홉 번째 세션, 사용자 제안 채택) — 아직 문서화 안 돼 있었음, 지금 확정. 위 "Modifier 필드로 막는 이유"/"Source/Store 값으로 막는 이유" 절은 타입 차단만 다뤘음 — Luau 타입은 런타임에 지워지므로(:Peek/Overridden/버그로 타입을 우회해 PreRef가 Modifier나 Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. 전용 Handler를 하나 등록: { isHandlable = function(inst,k,v) return isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수 있음") end }NoneHandler와 같은 결의 "한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이 Handler는 Dispatch.process/getHandler의 정상 우선순위 스캔에 등록되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의 PreRef는 pre-pass가 fire와 동시에 해당 슬롯을 소진(None 처리, nil 아님)해 정상 두 패스 스캔에 다시 노출되지 않게 하므로, 이 Handler가 실제로 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 새어들어온" 버그 케이스뿐 — 그래서 no-op이 아니라 즉시 error.
  • 일반 Ref는 계속 Modifier/Store 어디든 자유롭게 들어감 — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분).
  • quad v1의 OnCreated 특수 DI 키는 이식하지 않는다. Ref():Callback(function(inst) end)를 children 배열에 넣는 것만으로 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠.
  • :Wait()는 PreRef에도 그대로 유효해야 함. PreRef 자신의 fire는 항상 동기적이지만, :Wait()를 호출하는 코드가 task.spawn이 아니라 순수 coroutine로 실행 중이었다면(Roblox task 스케줄러의 순서 보장이 없는 컨텍스트) 호출 시점에 아직 안 채워져 있어 실제로 yield-resume이 필요한 경우가 생김 — "항상 동기적이니 :Wait()는 즉시 리턴할 것"이라고 단정해 구현을 특수화하면 안 됨, 그냥 보통 Ref와 동일한 대기자 리스트/coroutine.yield 구현을 그대로 씀. 문서화 필요: "채워졌는지 먼저 확인, 없으면 :Wait()" 방어적 패턴을 권장 관용구로 명시(콜백이 "이미 채워져 있으면 즉시 1회 호출"하는 것과 대칭되는, 값이 없을 수도 있다는 걸 항상 코드가 스스로 확인해야 한다는 Ref 전체의 일관된 원칙).
  • 프로퍼티/이벤트가 항상 children/Ref보다 나중에 세팅된다는 사실 자체는 "고치지" 않는다 — 두 패스 순서를 뒤집거나 재배치하는 시도는 오버엔지니어링으로 판단해 안 함(이걸 원하면 애초에 PreRef를 쓰면 됨). 이 결정과 이유는 나중에 quadnomicon 콘텐츠로 문서화 예정 (research/documentation-content-map.md 후보로 메모).

이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06)

결정: v1의 function(self, ...) 관습(self/this로 이벤트 대상 Instance를 넘겨주는 것, .claude/reference/quad-v1-architecture.md 참고 — 실제로 event.luaBindfunc(self or this, ...)로 넘겨줌)은 채택하지 않는다. quad-roblox의 이벤트 핸들러는 엔진이 네이티브로 주는 이벤트 인자만 받는다(React의 onXxx가 DOM 노드가 아니라 SyntheticEvent만 주는 것과 같은 모양).

근거:

  1. Ref가 이미 이 자리를 채움. "생성 직후/마운트 후 ref 채우기"가 되는 순간 Instance 접근이 필요하면 클로저 캡처로 해결됨(위 Ref 절) — self는 그와 중복되는 두 번째 채널일 뿐이고, 두 채널이 있으면 "어느 쪽이 authoritative냐"는 질문이 항상 따라붙음.
  2. thin wrapper를 제공하면 엔지니어링 구조 자체가 바뀜. self로 얻는 값이 mutable한 재바인드 가능 wrapper라면, 그건 Modifier의 정적 flatten(base/modifier-plan.md)과 항상 경쟁하는 두 번째 쓰기 경로가 생긴다는 뜻 — flatten된 뒤엔 wrapper 쪽에서 "이전 modifier가 뭐였는지" 재구성할 방법이 없음. Modifier 핸들러가 KV 매치 기반이라는 걸 감안하면, wrapper 값을 처리하려면 핸들러가 "이게 flatten된 정적 값이냐, 아니면 언제든 바뀔 수 있는 wrapper냐"를 매번 분기해야 함 — 오버엔지니어링이고 hot path(매 process 호출)에 분기 비용이 붙음. 반대로 raw Instance를 그대로 주는 선택지도 있지만, 그러면 quad가 스스로 지양하는 "quad가 모르는 직접 mutate 경로"를 공식 API로 만들어주는 셈이라 (3)과 충돌.
  3. 디버깅 관점에서 더 결정적. quad-debug의 가치 제안이 "무엇이 무엇에 연결됐는가"를 선언된 반응형 그래프로 추적하는 것인데 (research/debug-tooling-plan.md), self로 얻은 Instance를 이벤트 핸들러 안에서 직접 mutate하는 경로는 그 그래프 밖 — base/ purity-and-effects-plan.md의 "재사용 가능한 컴포넌트는 store만 파라미터로 받아야 한다"는 이식성 원칙과도 같은 결.
  4. 성능/GC: self를 넘겨주려면 원본 콜백을 클로저로 한 번 더 감싸야 함(event:Connect(function(...) func(self, ...) end)) — Connect마다 불필요한 클로저 할당 비용이 들고, 최적화에도 GC 흐름에도 좋을 게 없음. self가 없으면 사용자가 준 함수를 그대로 :Connect에 넘기면 충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로 (base/lifecycle-pattern.md, rbvm 선례 — GC-native), Destroy되면 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 불필요. [정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 지원하기로 확정 — 아래 "이벤트도 store-bind 가능 — false로 disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 메커니즘 불필요).

일반화: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — "엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 호출해줘도 무방하다"는 것. 다만 이벤트 등록 자체가 quad-roblox에만 있는 개념이라(다른 백엔드는 이벤트 모델이 다를 수 있음) 이건 base 문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 참고할 만한 템플릿 정도로만 취급.

이벤트도 store-bind 가능 — false로 disconnect (2026-08-06 후속 세션)

결정: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음.

엔지니어링 비용이 낮은 이유: 이미 확정된 "Store 바인드는 pluggable 바인드를 재실행하는 래핑"(위 절, 핸들러의 process가 값이 바뀔 때마다 Dispatch.process(inst,k,realv)를 재귀 호출) + "재실행 래핑이 retract도 같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. 이벤트 핸들러가 할 일은 딱 하나: process에서 :Connect()한 Connection을 per-instance 저장소에 기억해두고, retract에서 그걸 :Disconnect()하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(isHandlable/ priority/process/retract)만 제대로 구현하면 됨.

false로 disconnect, nil 아님. nil은 Lua 테이블에서 "키가 아예 없음"과 구별이 안 됨(pairs에서도 안 보임) — "명시적으로 꺼짐"이라는 신호를 값으로 전달하기엔 부적합. 대신 false(Luau에서 실재하는 싱글톤 타입)를 "연결 없음" 센티널로 씀: process(inst,k,false)가 들어오면 retract가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함. 이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 boolean 프로퍼티 핸들러와 (k, false) 매칭이 겹칠 위험 없음.

quad가 미는 기본 패턴은 아님 — 부차적 옵션. 저빈도 UI 이벤트(클릭류)를 조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러 하나를 계속 연결해두고 안에서 분기하면 끝:

MouseButton1Click = function()
    if not store.enabled:Get() then return end
    ...
end

이 "핸들러 하나 + 내부 분기" 패턴이 Connect/Disconnect 자체가 없어서 더 싸고, Roblox/React 어디서든 이미 익숙한 관용구라 기본 권장 패턴. store-bind 방식이 실제로 값어치 있는 지점은 고빈도 신호(Heartbeat/ RenderStepped/마우스 무브처럼 안 쓸 때 Connection을 살려두는 것 자체가 낭비인 경우)나, 단순 on/off가 아니라 로직 자체가 바뀌는 드문 케이스. 자주 재계산되는 State에 이벤트를 직접 물리면 매 재계산마다 Disconnect+ Connect가 도는 숨은 churn 비용도 있음(Store Set은 dedup 안 함, store-semantics.md) — 그래서 남용하지 말라는 캐비엇.

그래도 일관성 있게 지원은 해둠. "저빈도엔 필요 없다"가 "그러니 예외로 빼고 못 하게 막자"로 이어질 이유는 없음 — 프로퍼티/태그/어트리뷰트가 전부 store-bind되는데 이벤트만 특별 취급해서 뺄 근거가 약하고, 구현 비용도 낮으니(위 "엔지니어링 비용이 낮은 이유" 참고) 일관되게 지원해두는 쪽을 택함. 그냥 "이런 것도 가능하다" 정도로 존재하고, quad가 이 패턴을 적극 권장하진 않는다는 톤으로 문서화(research/documentation-plan.md 3번 "권장 이벤트 핸들링 패턴" 문서에 이 대조까지 반영 예정).

여러 Store 값을 묶어 파생값 만들기 — :With + :Compute, 포지셔널 인자 지양

사용자 확인 완료, 상세 방향 확정. 후보로 검토했던 두 방식 모두 기각:

  • 암묵적 자동 추적(Vide식 ambient stack) 기각 — "함수 실행 중과 끝 사이를 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔한 방법이 아니라고 판단.
  • 명시적 디펜던시 배열 + 포지셔널 인자(Store.Combine({a,b}, function(av,bv) ...))도 기각 — 두 가지 이유: (1) 팩토리 함수로 store-bind 처리기를 쉽게 못 만들어줌, (2) 여러 팩토리를 체이닝하면 인자 순서가 꼬일 수 있고, 타입 표기도 어려워짐.

채택 방향: :With(...)로 필요한 의존성을 모으고, 그 뒤 :Compute(function() ... end)에서 with한 값을 포지셔널 인자로 받지 않고 클로저로 직접 읽는다 (정확히 어떤 방식으로 "직접 읽는지"는 2차 라운드에서 확정 — self/with 값 둘 다 lazy State 핸들로 통일, 아래 "Store/State/Source 온톨로지" 절의 ":With/ :Compute" 부분 참고).

fn을 커링 스타일로 짜는 것도 권장(2026-08-07 일곱 번째 세션)key:Compute(makeFormatter("ko-KR"))처럼 팩토리가 실제 fn을 만들어 반환하는 패턴, Observer/Effect의 동일 관용구(아래 "fn을 커링 스타일로 짜는 것도 모듈화 관용구로 권장" 절, base/effect-plan.md)와 같은 결 — :Compute가 원래부터 이 셋 중 제일 먼저 있던 자리라 뒤늦게 문서화된 것뿐, 새 결정이라기보다 이미 있던 패턴을 명문화한 것.

:Compute(fn)의 선택적 두 번째 인자 — previous (무거운 파생 객체 재사용, 2026-08-06)

배경: :Compute의 결과가 그 자체로 무겁고 재생성 비용이 큰 엔진 객체일 수 있음(예: 큰 로케일 테이블을 Roblox LocalizationTable Instance로 변환하는 경우 — LocalizationTableSet/Get/List로 부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로 재사용해 필드만 patch하고 싶을 때를 위해, fn(value, previous) 형태로 직전에 이 Compute 함수가 반환했던 값을 두 번째 인자로 받을 수 있게 한다.

  • opt-in: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 — 비용 0. 대부분의 Compute는 이걸 쓸 필요 없음.
  • previous는 "바로 직전 버전"이 보장되지 않음. lazy pull 모델이라 중간에 여러 번 무효화됐어도 실제로 관측(Get()) 안 됐으면 재계산 자체가 안 일어남 — 그래서 previous는 몇 세대 전 값인지 알 수 없음. 따라서 previous를 다루는 로직은 반드시 "현재 입력 전체 대 이전 결과 전체"의 full diff여야 하고, "정확히 한 단계 전"이라고 가정하는 incremental delta 로직을 짜면 안 됨. 이건 React 자체의 reconciler가 하는 것과 같은 모양(old tree/new tree 전체 비교 후 실제 host 객체에 패치 적용)이라 새로 발명하는 패턴은 아님.
  • 최종 소비처가 patch된 값을 다시 한번 Set/Parent하게 되는 경우가 있어도(레퍼런스는 같은데 다시 대입) 대체로 치명적이지 않음(Roblox 프로퍼티 재대입은 저렴/멱등인 경우가 대부분) — 문서화만 해두면 충분.

⚠️ 이 패턴을 쓸 때 반드시 같이 지켜야 하는 것 — "확정(관측)되기 전엔 연산이 없다". previous를 mutate하는 로직은 Compute 함수 본문 안에 있으므로, 그 함수가 재실행되지 않으면(=아무도 다시 Get()하지 않으면) mutation 코드 자체가 아예 실행되지 않는다 — 단순히 "가끔 stale하다" 수준이 아니라 영영 갱신이 안 일어날 수 있음. 이 패턴으로 만든 State는 반드시 다음 중 하나로 계속 능동적으로 관측되어야 함:

  1. quad의 정상적인 선언적 prop 바인딩 경로([Property "X"] = someState 류)에 실제로 물려있어서, dispatch 엔진이 무효화 시 자동으로 재Get()하게 되어 있거나,
  2. 아래 "Observer" 절의 state:Observer(fn) + 콜백 안에서 명시적 Get() 호출 + 그 결과를 children 배열에 넣어 라이프사이클에 묶어두기. "Ref로 한 번 얻어서 수동으로 Parent만 하고 끝"처럼 능동적 관측 경로가 안 남아있으면, 이 최적화는 그냥 조용히 작동을 멈춘다.

state:Observer(fn) — 값을 안 실어주는 구독, children 배열에 직접 놓는 leaf 값

결정(2026-08-06 후속 세션, 사용자 확정): 별도 ObserverHolder 래퍼 타입은 안 만듦 — state:Observer(fn)가 반환하는 값 자체가 이미 "children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. Ref와 완전히 같은 층위. 자유 함수 Observer(state, fn)가 아니라 메소드 state:Observer(fn)로 확정state가 항상 필요한 필수 인자라 : 리시버 자리에 자연스럽게 들어가고(다른 형태면 인자 두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), architecture.md의 "함수지향 디폴트, : 체이닝은 예외적으로만(체이닝이 정말 편한 경우만)" 원칙이 정확히 이 경우를 가리킴 — Store 값 변경 체이닝과 같은 예외 카테고리. 더 근본적인 이유: base/store-semantics.md의 "독립 존재 가능한 프리미티브 vs 원천에 종속된 파생 데이터" 원칙 참고 — Observer는 State처럼 원천 없이는 존재할 수 없는 파생 데이터라, 애초에 "타입 이름을 부르는 자유 함수 생성자" 카테고리에 안 속함(Source/Ref/Store/ Modifier와는 다른 부류).

local observer = state:Observer(function()
    state:Get()
end)

Frame {
    observer,
}

이러면 observerFrame이 살아있는 동안만 유지되고, Frame이 retract/Destroy되면 자동으로 정리됨.

  • fn은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, 사용자 확정 — 이전까지 미명시였던 항목). 근거: (1) 이미 채워진 State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회 실행을 하지 말아야 할 구체적 근거가 약함. (3) 이 결정 덕에 Observer 하나로 "초기값 적용"과 "이후 변경 반영"을 같은 코드 경로로 통일할 수 있음 — 예: State→프로퍼티 store-bind 핸들러가 그냥 state:Observer(function() inst.SomeProp = state:Get() end)를 걸어 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 따로 안 짜도 됨). state:Observer()(인자 없는 "항상 관측" 유틸)도 이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨.
  • 값을 안 실어줌 — 반드시 Get()을 다시 해야 함. 기존 "emit은 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(아래 "Store/State/Source 온톨로지" 절)이 그대로 적용됨: fn은 "뭔가 바뀌었으니 다시 확인하라"는 신호만 받고 새 값 자체는 안 받음 — 위 예시처럼 fn 본문에서 state:Get()을 명시적으로 다시 읽어야 함. 자동으로 안 해주는 이유: 재계산이 진짜 필요한지가 다른 :With한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 noprint 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — Get() 호출 여부를 작성자가 직접 결정하게 열어둔 것.
  • fn을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 번째 세션)state:Observer(makeLogger("x"))처럼 팩토리가 실제 fn을 만들어 반환하는 패턴, ModifierBoldify(10) 커링(modifier-plan.md 8번)과 같은 결. base/effect-plan.md의 Effect도 동일하게 권장.
  • base가 제공하는 것은 isObserver류 타입 판별자 하나 — children 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 Ref와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array 참가자 패턴의 반복.
  • 콜백 실행은 기존 canExecute predicate로 게이팅(Slot 생존 확인과 동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님) — 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op.
  • 구현 노트(사용자 제안, 확정된 아키텍처는 아니고 구현 시 참고): 살아있는 Observer 집합을 Observer 값 내부 필드로 안 두고, 외부에 weak table({[observer] = true}, __mode = "k")로 인덱싱하는 방식을 선호 — 포인터 해싱 비용만 들고 값 자체엔 부작용 없음. rbvm의 getNamespaceOf류가 비슷한 외부 weak-table 인덱싱을 씀 (base/lifecycle-pattern.md 참고).
  • 인자 없는 state:Observer() — "항상 관측" 유틸. fn을 생략하면 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "previous 인자" 절의 캐비엇("능동적 관측 경로가 안 남아있으면 mutate 로직이 조용히 멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 하면 별문제 없음(사용자 판단).

state:Apply(factory) — Modifier와 동일한 순수 체이닝 설탕으로 확정 (2026-08-07 일곱 번째 세션)

처음 제안됐던 ":With/:Compute 등록을 커링으로 자동화하는 조합기" 방향은 기각됨 — 사용자가 재확인한 실제 의도는 그보다 훨씬 단순함. Modifier:Apply(factory)도 매번 새 값을 만들어내는 체이닝 설탕일 뿐이듯, State/Source도 :With/:Compute마다 새 노드가 나오는 같은 모양이라 — state:Apply(factory)는 그냥 factory(state)를 메소드 체이닝 문법으로 쓴 것뿐이고 그 이상의 계약은 없음(Modifier:Apply와 완전히 동일한 정의: function(self, factory) return factory(self) end).

  • 동기: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면,
    -- Apply 없이: 안쪽(가장 최근에 만든 것)부터 거꾸로 읽어야 함
    local capped = capAt(100)(withLocale(localeStore.locale)(rawScore))
    
    -- state:Apply로: 왼쪽에서 오른쪽, 만든 순서 그대로 읽힘
    local capped = rawScore
      :Apply(withLocale(localeStore.locale))
      :Apply(capAt(100))
    
    팩토리가 세 개, 네 개로 늘어날수록 앞쪽 버전은 괄호 깊이와 읽는 방향이 코드 작성 순서와 반대로 꼬여 diff/리뷰에서 특히 안 좋음 — :Apply 버전은 각 줄이 "그다음 뭘 했는지"를 순서대로 나열하므로 Modifier 체이닝(mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify))과 읽는 방식이 완전히 통일됨. :With/:Compute 자체를 대신 호출해주는 자동화가 아니므로, 여전히 팩토리 본문 안에서 :With/:Compute를 직접 호출하는 건 팩토리 작성자 몫.
  • 구현 비용 거의 0: Modifier와 달리 State/Source는 제네릭 __index로 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 존재), Modifier의 Apply처럼 "필드 이름으로 예약해야 하는" 충돌 자체가 없음 — 그냥 고정 메소드 하나 추가하는 것.
  • 타입은 factory: (State<T>) -> U): U로 완전히 열어둠 — Modifier의 Applyfactory: (M) -> M으로 같은 타입을 유지해야 체이닝이 이어지지만, State의 :Apply는 팩토리가 State가 아닌 값(예: 최종 요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도 막을 이유가 없음 — Modifier보다 오히려 더 자유로운 시그니처.
  • Source도 자동 포함: Source가 State를 구조적으로 만족하는 기존 델리게이션(__index:With/:Compute 위임)에 :Apply도 그대로 얹히므로 별도 구현 불필요.
  • Effect/Observer/Compute의 fn 커링 권장(위 절들)과 같은 스레드지만 별개 기능 — 커링은 "fn 자체를 팩토리로 짜는 관용구" 권장이고, :Apply는 그렇게 만든 팩토리를 체이닝 문법으로 적용하는 수단. 둘이 합쳐지면 state:Apply(makeFormatter("ko-KR"))처럼 자연스럽게 이어짐.

Observer/Effect의 :Subscribe()/:Unsubscribe()는 이 절과 무관한 별개 주제 — 아래 새 절로 분리(이전에 이 헤더 아래 잘못 걸려 있던 문서 버그 수정, 내용 자체는 이미 확정된 것 그대로).

Observer의 :Subscribe()/:Unsubscribe() — children 배열 밖 독립 구독 (2026-08-06 후속 세션)

문제: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 "어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 전제를 깨뜨림 — 개발자가 디버깅용으로 RunService:IsStudio() 가드 안에서 Store에 직접 Observer를 걸어 print하는 패턴(원하면 BooleanValue 로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table 기반 자동 추적이 적용 안 됨.

해결: 명시적 :Subscribe()/:Unsubscribe()를 추가로 지원. 이건 새 설계가 아니라 bind-system-plan.md의 PA님 코드 교차검증(라이프사이클 절)에서 이미 예고해둔 확장 지점을 실제로 채우는 것 — "나중에 GC만으로 정말 부족한 케이스가 생기면 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인"이라고 그때 이미 못박아뒀음.

  • local 변수로 참조만 들고 있는 것으로는 부족한 이유: 토글(BooleanValue로 로깅 껐다 켰다) 케이스에서, 참조를 끊어도 실제 GC는 결정론적으로 즉시 일어나지 않음 — "껐다"고 생각한 뒤에도 한동안 계속 발화할 수 있음. :Unsubscribe()는 즉시/결정론적으로 끊는 경로라 이 문제가 없음.
  • liveness 체크는 필드 우선, weak table은 폴백(사용자 제안): 외부 weak table 조회보다 리터럴 필드 접근이 더 쌈(Luau가 문자열 키 접근을 미리 해시해둠) —
    if self.Subscribed then return true end
    if self.Connection then return self.Connection.Connected end
    
    자동(리프 부착)/수동(구독) 두 라이프사이클 경로를 하나의 canExecute류 predicate로 OR 묶는 자연스러운 형태. 실측은 구현 단계에서 확인.
  • 내부 강참조 레지스트리: SubscribedObservers: {[observer]: true}류를 weak 아닌 강참조로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적 자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림 (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). :Unsubscribe()는 이 레지스트리에서 반드시 SubscribedObservers[observer] = nil까지 해야 함Subscribed 플래그만 내리고 강참조를 안 끊으면 GC 대상이 안 되는 반쪽짜리 해제가 됨, 둘은 항상 같이 일어나는 한 세트.
  • :Subscribe()/:Unsubscribe() 둘 다 idempotent — 이미 구독 중인데 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌.
  • :Unsubscribe()는 자동(리프) 케이스에도 동일하게 씀 — Instance가 파괴되기 전에 수동으로 조기 해제하고 싶을 때도 같은 메소드 하나로 충분, 별도 API 안 만듦.
  • state:Observer(fn):Subscribe()처럼 참조를 아무 데도 안 담아도 정상 — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 포인트).
  • :Subscribe()/:Unsubscribe() 둘 다 self를 리턴(대칭)local obs = state:Observer(fn):Subscribe()처럼 "구독 시작 + 나중에 끊을 핸들 확보"가 한 줄로 되고, table.insert(subs, state:Observer(fn) :Subscribe())처럼 리스트에 담을 때도 줄바꿈 없이 됨. Observer가 immutable 값이 아니라 원래 mutable한 구독 핸들이라 fluent 체이닝이 자연스러움 — Modifier의 clone-then-return 체이닝과는 다른 이유(같은 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능.

이중 바인딩 금지 — leaf 부착과 :Subscribe()는 상호 배타적, Bound 플래그로 즉시 에러 (2026-08-07 일곱 번째 세션)

규칙: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 (위 weak table 경로) :Subscribe()로 수동 등록되거나(위 강참조 레지스트리 경로), 둘 중 하나만. 둘 다 동시에 걸리는 건 UB로 확정 — 이미 leaf에 부착된 핸들을 다시 :Subscribe()하는 것도, 이미 :Subscribe()한 핸들을 children 배열에 놓아 leaf로도 부착시키는 것도 둘 다 금지.

UB를 조용한 오동작이 아니라 즉시 에러로 만든다 — 판별 비용이 사실상 0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈:

-- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인
if self.Bound then
  error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음")
end
self.Bound = true
  • Bound는 가칭 — 용어 정리 라운드에서 최종 이름 재검토 대상 (.claude/question.md에 반영).
  • 이 플래그는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 표시 — 두 진입점이 똑같이 확인/설정하므로 순서와 무관하게 대칭적으로 막힘.
  • :Unsubscribe()는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 뜻으로 통일Bound가 어느 경로로 세워졌는지와 무관하게, :Unsubscribe() 한 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 강참조 등록이든)을 끝내고 최종 정리를 수행. 위 ":Unsubscribe()는 자동(리프) 케이스에도 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 별개로, "단일 바인딩을 끊는" :Unsubscribe() 자체의 계약은 안 바뀜.
  • Effect도 동일 규칙 적용 — 내부적으로 Observer를 조합하는 경우든 state 없는 경우든 같은 Bound 게이트를 그대로 재사용 (base/effect-plan.md). 이전에 그 문서에 적어뒀던 "leaf 부착과 :Subscribe()를 동시에 쓰는 것도 안전"이라는 서술은 이 규칙으로 대체(정정) — 안전하게 지원하는 게 아니라 애초에 막아야 하는 조합이었음.
  • 문서화 경고 대상(api/심화): "한 Effect/Observer 핸들을 children 배열에 놓았다면 그걸 다시 :Subscribe()하지 말 것, 반대도 마찬가지 — 두 경로를 동시에 쓰고 싶으면 각각 독립된 새 Effect(...)/ state:Observer(...) 호출로 따로 만들 것"을 명시할 것.

Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨

배경: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. 이상적으로는 store에서 한 값을 추적(track)하면 "State"가 나오고, 거기에 compute를 적용하면 또 다른 "State"가 나오는 식 — Unix의 (cat a; cat b) | while read ...처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최종 목표. :With의 두 번째 인자(b)도 다른 :Compute의 결과물(State)을 그대로 받을 수 있어야 이상적.

해소됨(2차 라운드) — 두 갈래 방식 중 실질적으로 옵션 2 방향으로 정리됨: 당시엔 (1) Compute 체인이 자기 자신을 mutable하게 바꾸는 방식(엔지니어링 비용 낮지만 공유/합성이 깨짐) vs (2) 명시적 State:fromState(state)류 비-mutating 생성자(합성은 안전, 비용 미확정) 둘로 긴장이 있었으나, 실제 확정된 모델은 아래 "Store/State/Source 온톨로지" 절의 state(state)로 기존 state의 결과를 받아 새 state를 만드는 조합임 — 매번 새 State를 만든다는 점에서 옵션 2와 같은 축(비-mutating)이고, 별도 fromState/Pipe 콤비네이터 타입 없이도 state(state) 하나로 충분하다는 게 최종 결론(Pipe 후보는 폐기). base/architecture.md의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 같은 축의 해법.

Store/State/Source 온톨로지 — 핵심 메커니즘 확정 (2026-08-04 2차 라운드)

상태: 전파 모델/:Compute 인자 규칙/State 쓰기 금지/Slot 생존 확인/타입 추론(dot-access) 전부 AskUserQuestion으로 확인 완료. 남은 건 정확한 함수/ 생성자 이름뿐(구현 단계). base/store-semantics.md의 "State 프리미티브는 실제로 필요하다" 정정에서 이어짐.

핵심 온톨로지 (2026-08-06 후속 세션에서 Store/Source 부분 정정 — 아래 "State는 쓰기 대상이 아님" 절 이후 내용 및 base/store-semantics.md의 "Source가 State를 만족함" 절 참고):

  • Source — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). 구조적으로 State를 만족(단방향 호환):Get()/:With/:Compute 전부 지원 위에 :Set(value)/:Emit() 추가.
  • Store — Source들의 이름 붙은 모음, 그 이상 아님. store.a처럼 키로 접근하면 이미 만들어진 Source가 있으면 그대로 반환, 없으면 그 자리에서 만들어 저장한 뒤 반환(더 이상 별도 State wrapper를 매번 만들거나 따로 캐싱하지 않음 — Source 자체가 이미 State를 만족하므로 wrapper 계층 자체가 불필요해짐. [정정, 2026-08-07] "Store 생성 시 전부 eager하게만 만들어진다"는 이전 서술은 부정확 — defaults가 선택이고 Luau 타입이 런타임에 강제 안 되므로, 생성 시점 eager 생성(각 defaults 키)과 store.key 접근 시점 lazy 생성(아직 없는 키를 그 자리에서 만듦)이 둘 다 필요함, 상세는 base/store-semantics.md 참고).
  • State — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 독립적 value 개념이 없음. state(state)로 기존 state의 결과를 받아 새 state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 합성 가능"이라는 원래 목표를 구현하는 방식.

전파 모델 확정: push-invalidate(신호만) / pull-recompute(Get() 시점에만) — Fusion식 eager 노드·생성순 정렬은 안 만듦

  • Source는 값이 바뀌면 구독 중인 State들에게 "무효화됐다"는 신호만 쏜다 — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구).
  • 신호를 받은 State는 자기 invalid 플래그만 세우고, 이미 invalid였다면 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 장치(Vide가 저자 스스로 todo.md에 미해결로 남긴 문제의 해결책).
  • 실제 재계산은 :Get()이 호출되는 시점에만 일어남 — "필요할 때 계산" 원칙(사용자 확정). Fusion의 timeliness="eager" 노드/ 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 소비자가 없다는 판단. 유일하게 "즉시 반응해야 하는" 소비자는 store-bind pluggable 핸들러(위 "확정된 디스패치 모델" 절)인데, 이건 무효화 신호를 받는 즉시 자기가 알아서 Get()을 호출해 pull하는 방식으로 충분함 — State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀 필요 없음.
  • emit은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함 ("emit 필요 여부" 열린 질문은 이걸로 해소).

전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)

위 pull-recompute 규칙을 State 하나의 재계산 메커니즘으로만 읽지 말고, 프로젝트 전역에 적용되는 원칙으로 명시함: 어떤 파생값도 :Get()으로 직접 읽히기(관측) 전까지는 계산되지 않는다. 이 원칙은 State 자체뿐 아니라, State를 필드 값으로 담고 있는 다른 구조(예: base/modifier-plan.md의 Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 읽으면 그 순간이 바로 관측이고, 그 순간 계산이 확정됨.

주의 — 구조적 복사는 관측이 아님. table.clone처럼 테이블 레퍼런스만 복사하는 연산은 안에 담긴 State 핸들을 그대로 옮길 뿐 :Get()을 호출하지 않으므로 관측이 아니고, 계산을 트리거하지 않음. Modifier 체이닝 메소드가 table.clone 후 필드를 덮어쓰는 것(위 "Immutable 값 + clone 기반 체이닝")과 이 원칙이 충돌하지 않는 이유가 바로 이것 — clone은 그저 참조 복사라 State 필드는 클론 이후에도 여전히 살아있는 lazy 핸들로 남음.

왜 State 체인을 Modifier처럼 플래튼하지 않는가 (2026-08-06 후속 세션)

문제 제기(사용자): State가 a → b → c처럼 계속 연결되는 구조면, 이전 노드가 다음 노드에 대한 emit 연결/값 연결을 항상 들고 있어야 함(weak table로 GC는 되지만 별도 데이터스트럭처 관리 부담). 대안으로, 각 State가 자기 Compute 함수 목록을 통째로 누적해서 갖고(Modifier의 clone-then-return 체이닝처럼) 매번 클론+append하면 링크드 그래프 자체가 필요 없어지지 않는가?

기각 이유 — State의 정의 자체가 "캐싱하는 존재"임. 위 온톨로지에 "State — source(또는 다른 state)의 결과를 캐싱만 하는 존재"라고 확정돼 있고, previous 두 번째 인자 메커니즘(무거운 파생 엔진 객체 재생성 비용 절감)도 이 캐싱 전제 위에서만 의미가 있음. 만약 Compute 체인을 매번 통째로 클론해 각 leaf가 독립된 함수 목록을 갖게 하면, 중간 State를 여러 갈래가 공유하는 다이아몬드 형태(b에서 c1 = b:Compute(g1), c2 = b:Compute(g2)로 분기)에서 b까지의 계산이 캐시 공유 없이 소비자 수만큼 중복 실행됨 — previous 메커니즘이 막으려던 문제를 반대로 다시 만들어내는 셈이라 방향이 안 맞음.

"별도 데이터스트럭처 관리" 부담은 실제로는 작음. "관측해야 실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건 실제로 관측되는(Get()되는) State뿐 — 중간에 만들어놓고 아무도 안 보는 State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 워크를 막는 invalid 플래그 dedup 장치도 체인 전체가 링크드일 것을 요구하지 않고 각 노드가 자기 구독자 목록만 가지면 되는 것이라, 이 결정과 무관하게 그대로 유지됨. 구현은 Observer와 동일한 패턴(외부 weak table, {[child] = true} 류)으로 충분 — 새 메커니즘 발명 아님.

결론: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음. research/documentation-plan.md의 심화 문서 후보로 남겨둠 — "왜 State는 Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사용자를 위한 좋은 심화 콘텐츠 소재.

:With도 새 State 노드로 확정, 가변인자로 체인 남발 방지 (2026-08-07)

문제 제기(사용자): :With(...)가 문서상 가변인자 표기이긴 한데, 실제로 호출마다(:With(a):With(b):With(c)처럼 체이닝할 때) 매번 새 State 노드를 만드는 게 맞는지, 아니면 값 없이 의존성 목록만 clone-then-append로 누적하는 가벼운 빌더로 만들어 노드 증식을 피해야 하는지가 불명확했음.

"빌더" 대안은 기각. 세 가지 이유:

  1. 디버그 그래프가 꼬임. quad-debug의 핵심 UX는 "무엇이 무엇에 연결됐는가" 그래프(research/debug-tooling-plan.md). With/Compute를 전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로 대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는 지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음.
  2. 다이아몬드 dedup을 못 타고 특수 케이스가 생김. With가 진짜 노드면 w = key1:With(key2)에서 갈라지는 c1 = w:Compute(g1), c2 = w:Compute(g2) 같은 흔한 fan-out이 이미 확정된 "invalid 플래그로 다이아몬드 중복 워크 방지" 장치(위 "전파 모델 확정" 절)를 그대로 재사용함. 빌더면 c1/c2가 key1/key2에 각자 직접 구독을 걸어야 해서 기존 dedup 경로를 매번 우회하는 특수 케이스가 생김.
  3. clone 기반 구현은 Compute 노드 위에서 실제로 깨짐(사용자 지적, 검증 완료). c = a:Compute(f) 뒤에 w = c:With(b)를 clone으로 구현하면, table.clonec의 캐시 슬롯(계산된 값 + invalid 플래그)까지 그대로 복사해 wc와 별개의 독립 캐시를 갖는 사실상 다른 노드가 됨. cw가 각자 관측되면 f가 두 번 따로 실행/캐싱됨 — 바로 위 "왜 State 체인을 Modifier처럼 플래튼하지 않는가" 절에서 이미 기각한 것과 정확히 같은 실패 모드(공유돼야 할 계산이 소비자 수만큼 중복 실행). Modifier의 clone-then-append 패턴을 State 쪽에 그대로 가져오면 안 되는 이유가 바로 이것.

결정: :With(...)는 호출마다 self+주어진 인자들을 구독하는 새 State 노드를 만든다(레퍼런스 기반 구독, clone 아님) — 계산 함수는 없고 값은 self를 그대로 통과(pass-through)시키되 구독 목록만 넓힌 얇은 노드. 이 노드는 Observer와 같은 패턴(외부 weak table)으로 상위 노드의 구독자 목록에 등록됨.

노드 증식 걱정은 가변인자로 해소. 처음 문제 제기("With 하나마다 노드가 하나씩 늘어나는 게 낭비 아니냐")는 노드 자체를 없애는 대신, :With(...)가 여러 의존성을 한 번에 받을 수 있게 해서 해소함:

  • key1:With(a, b, c):Compute(fn) — 노드 1개(구독 3개)로 끝남.
  • key1:With(a):With(b):With(c):Compute(fn) — 여전히 가능하지만 노드 3개가 만들어짐. 이건 나쁜 게 아니라 각 노드가 dedup/디버그 그래프에서 실제 역할(구독 fan-in 지점)을 하는 저렴한 노드(계산 없음, Modifier clone과 같은 급의 비용)라 걱정할 비용이 아님.
  • 그래도 가변인자 스타일을 권장 관례로 삼음 — 그래프로 그릴 때도 :With(a, b, c):With(a):With(b):With(c)보다 단순(노드 1개에 들어오는 엣지 3개 vs 노드 3개가 순서대로 이어지는 모양)해서 디버그하기 쉬움 (사용자 확인).

:With/:Compute — self 인자도 lazy 핸들로 통일

  • 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 단점이 있었음 — self가 raw 값이면 fn 호출 전에 항상 self를 먼저 Get()해야 하므로, fn 내부 로직이 with한 다른 값을 보고 "이 경우엔 self 계산 자체가 필요 없다"고 판단해도 이미 늦음(예: :With(noprint)이고 noprint:Get() == true면 앞단 계산을 통째로 생략하고 싶은 경우).
  • 해결(사용자 확정): self도 raw 값이 아니라 State 핸들 그 자체fn의 포지셔널 인자로 넘긴다 — fn(self: State<T>), 내부에서 self:Get()을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한 lazy 원칙을 self에도 그대로 적용 — 별도 ComputeWithout 변형은 불필요, Compute 하나로 일관.
  • [정정, 2026-08-07] .value는 State/Source에서 제외, :Get()만 지원. 이전엔 Get()을 감싼 읽기 전용 계산 속성(base/lifecycle-pattern.mdConnected와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로 .value/:Get() 둘 다 지원하고 .value를 관용적 표기로 앞세웠으나, "관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서 프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출 :Get() 하나로 좁힘 — :Set()과의 동사 짝도 자연스러움. .value 표기 자체는 폐기하지 않고 Ref 전용으로 좁힘(Ref는 lazy가 아니라 값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 — 이 절 위쪽 "Ref 일반화" 절의 .Value가 그대로 유일한 존재가 됨, 이름 충돌 자체가 사라져 별도 표기 정리 불필요).
  • 예시 갱신: store "key1":With(store "key2"):Compute(function(key1) return key1:Get() + store.key2:Get() end)key1은 이제 raw 숫자가 아니라 State.

State는 쓰기 대상이 아님 — 확정, Source는 독립 공개 프리미티브로 격상

  • state:Get()은 항상 읽기 전용. State에는 쓰기 API가 아예 없음. "State에 직접 쓰기 API를 허용하면 다른 source에서 파생된 state에 직접 쓰기가 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가 없음).
  • [정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 store.key = value (__newindex)가 아니라 store.key:Set(value)로 전환됨 — 이유와 상세는 base/store-semantics.md의 "Store 값 설정 문법" 절 참고(요지: Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을 맞추려면 대입 문법을 포기해야 함 + =가 암시하는 "즉시 커밋"이 실제 lazy 동작과 정서적으로 안 맞는다는 논거). 같은 문서의 "Source가 State를 만족함" 절에 Source/State 서브타입 구조 전체가 정리돼 있음.
  • Source는 Store의 내부 구현 디테일이 아니라 별도의 가벼운 공개 프리미티브로 노출 — Store는 다수의 source를 등록/관리하는 무거운 구조라, 값 하나만 반응형으로 다루고 싶을 때 Store를 통째로 만드는 건 비효율이라는 게 사용자 판단("store가 source 수십 개 만드는건 비효율이니 둘이 다른 구현이라 봐도 될듯"). Source(initial) 류의 독립 생성자 (정확한 이름은 구현 단계에서 확정)가 Store와 나란히 존재.
  • 생성자 스타일 확정(2026-08-06 후속 세션): Kotlin Compose식 "타입 이름 자체를 팩토리 함수로" — Source(default), Ref(default), Store({defaults}). Ref도 예외 없이 이 스타일을 따름 — Ref가 Ref()로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만 다루고 생성자를 명시 안 해서 생긴 공백), architecture.md의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. Store({defaults})도 같은 스타일로 지원(defaults는 선택 — 안 주고 Store()만 호출해도 됨, 순수 편의용 초기값 템플릿). [정정, 2026-08-07] 아래 두 문장은 이후 라운드에서 정정된 옛 서술 — 실제 메커니즘·mutate 취급은 base/store-semantics.md "Source가 State를 만족함" 절이 최종 소스: (a) "__newindex/__index 프록시로 감싸면 됨"은 이후 store.key = value 쓰기 문법 자체가 :Set()으로 옮겨가며 __newindex는 더 이상 관여 안 함(읽기 쪽 __index는 "없으면 그 자리에서 Source를 만들어 저장"하는 lazy 생성 용도로 여전히 필요, 위 store-semantics.md 참고). (b) "defaults 테이블 원본을 직접 mutate하는 건 UB로 둠"도 최신 모델과 안 맞음 — defaults는 라이브 백킹 스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 쓰이므로, 생성 후 원본을 바꿔도 문제없고 UB가 아님.

Slot 생존 확인 — 별도 메커니즘 아님, canExecute 재사용으로 확정

  • base/store-semantics.md에 있던 "isInit=false면 허용, isInit=true+ 생존확인 거짓이면 불허" 분기 초안은 폐기. state-invalidate 리스너 클로저도 base/lifecycle-pattern.md의 "생명 바인드 유틸"(canExecute predicate)로 등록하면, 발화 시 canExecute(handle) 하나만 확인하고 거짓이면 그냥 no-op — isInit 분기라는 별도 개념 자체가 불필요(사용자 확정: "canExecute 하나로 통일").

타입 추론 문제 — 확정(2026-08-04 3차 라운드)

  • store "key"(문자열 커링)로 state<T>를 오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고, store.key(dot-access)를 1급 경로로 확정 — Store 타입을 {key: Source<number>, other: Source<string>}류 평범한 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 State<T> 필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 Source<T>로 갱신 — store.key = value 쓰기 문법이 :Set()으로 옮겨가 이 필드가 더 이상 __newindex로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 해소됨, base/store-semantics.md "Source가 State를 만족함" 절 참고). store "key" 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(Source<any>) 폴백으로 격하.
  • 이 패턴은 Store에만 국한되지 않고 인스턴스 생성까지 관통하는 프로젝트 전역 관습으로 확정됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 유일한 예외로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). 아래 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 최신 확정 내용.

Pipe(quad2-try 후보)는 폐기 확정 — 별도 Pipe 타입에 소유권/버전 가드를 넣어 재설계하는 대신, State 자체가 파이핑 결합체이고 state(state)로 분기하는 위 모델로 완전히 대체됨.

PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지

.claude/initreq/artworks/EventDrivenProgramming/(Connection/Event/ Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실제로 다른 선택이 나와 재검토했으나 결론은 변경 없음. 이름 주의: 아래에서 말하는 Observer는 PA님 코드의 클래스 이름(pub-sub, 8개 subscribeXxx 헬퍼)이고, 위 "state:Observer(fn)" 절에서 확정한 quad의 Observer와는 이름만 같을 뿐 무관한 별개 개념 — 이 절은 순수 역사적 교차검증 기록으로만 읽을 것.

  • 전파 모델: PA님의 pub-sub은 push-invalidate가 아니라 push-값 (Event:fire(...)가 인자를 그대로 콜백에 전달, Observable__newindex가 새 값을 실어 즉시 changed:fire(key, value), dirty-flag/Get() pull 단계 자체가 없음). 한때 "leaf(source 하나→sink 하나, 파생 없음)는 PA님처럼 push-값으로 단순화하고 push-invalidate/pull-recompute는 실제 :Compute 파생이 있을 때만 쓰자"는 이원화를 검토했으나 기각 — invalidate+Get() 방식도 leaf에서 딱히 더 복잡하지 않고(불리언 플래그 하나 + Get()/emit 둘로 나뉘는 정도), 오히려 두 메커니즘을 병행하면 "leaf State가 나중에 :Compute로 감싸일 때 두 메커니즘을 어떻게 연결하는가"라는 새 경계 문제가 생겨 이원화가 더 복잡함. 결정적으로, PA님 코드엔 애초에 :Compute/:With 같은 파생·합성 개념 자체가 없음 — quad-v2가 lazy pull을 도입한 이유(여러 소비자가 하나의 파생 State를 공유할 때 오염 방지, 안 쓰이는 연산 스킵)를 PA님 시스템은 처음부터 안 풀려던 문제라, 대등한 반례가 아니었음. 결론: push-invalidate/pull-recompute로 통일 유지, 변경 없음. 사용자 최종 확인 문구: "store 전파 처리는 우리 방식이 맞음. 이건 vide 에서 없었던것과 동일함, [PA님] 저기도 디자인 상 해결 못하는 문제가 된거거든. 비 필요 연산과 중복 연산을 지우는건 디자인 단계에서 구성할 일임. 우린 디자인 단계부터 해당 문제를 해결하고 싶었던거야."
  • 라이프사이클: PA님 코드는 GC-native가 아니라 전부 수동 해제 (Connection.connected는 계산 속성이 아니라 저장된 bool, Observer의 8개 subscribeXxx 헬퍼 전부 명시적 :unsubscribe() 필요, weak table은 Observable의 subject↔observable 캐시 한 곳뿐). rbvm 기반으로 확정한 "GC 위임, 명시적 dispose 없음" 원칙과 반대 선택이라 재확인 질문했으나, GC-native 유지로 확정 — 지금까지 이 정도 규모(명시적 dispose가 꼭 필요할 만큼 큰 자원)를 요구하는 실제 사례가 없었다는 게 사용자 판단. 다만 완전히 막다른 길은 아님을 기록해둠: rbvm처럼 관계를 양쪽 다 weak-keyed로 두고 모든 걸 connection 람다에 담아 "연결이 살아있는 동안만 살아있게" 하는 방식이면, 나중에 GC만으로 정말 부족한 케이스가 생겨도 그 connection을 얻어 disconnect()하는 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인 — 지금 마일스톤에서는 필요 없어서 안 함(사용자: "필요하다면 dispose 핸들러를 만들어주는 것도 가능한 디자인, 다만 지금까지 요구가 없었음"). (rbvm의 GC-native 패턴이 실물에서 검증됐다는 근거는 base/lifecycle-pattern.md 상단 참고 메모 참고.)

quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지

.claude/initreq/quad2-try/out/quad-core에 정확히 이 문제(Unix 파이프 영감의 State/스트림)를 다뤘던 이전 시도가 있었음. 조사 결과 요약:

확인된 죽은 접근 — 절대 반복하지 말 것:

  • OOP 상속(Base:Extends) 구조Source/State/Pipe/Store/Event/ Action+8개 서브타입 전체에 퍼져 있었음 — 모든 서브클래스 생성자마다 self._super._constructor(self, ...)를 수동으로 호출해야 하고(빼먹기 쉬움, 컴파일러가 검증 안 함), private/protected는 _ 접두사 관례일 뿐 실제 캡슐화가 전혀 없었으며, Base:IsInstance가 수동 유지되는 _proto/_super 연결 리스트를 순회하는 런타임 전용 타입 체크라 Luau 정적 타입 시스템이 전혀 못 봄. 사용자가 우려한 그대로 확인됨 — 상속 기반 설계 금지.
  • --& 커스텀 파서 시도는 완전히 죽은 코드였음 — 6개 파일에 156줄의 주석 기반 타입/가시성 어노테이션이 있었지만, 이걸 실제로 소비할 도구 (quad-gen, quad-lang)는 둘 다 완전히 빈 디렉토리였음. 오타(@clsas@class로 못 고침)가 안 잡힌 채 남아있었고, 같은 주석 마커 아래 전혀 다른 Lua5.1-호환 트랜스파일러 지시어까지 섞여 있었음 — 파서가 한 번도 제대로 동작한 적 없다는 명백한 증거. 확인대로 반복 금지.
  • Slot은 이 시도에서도 사실상 빈 스텁이었음 — Insert의 실제 구현부가 전부 주석 처리되어 있고, Notify()도 빈 함수. 심지어 구 v1(quad-2)의 DEV_CHANGELOG.txt에도 "TODO: slot 기능 구현"이 마지막까지 미완료로 남아있었음 — 가져올 게 전혀 없음, base/slot-plan.md의 from-scratch 설계를 그대로 진행하면 됨(재조사 불필요).
  • 다른 서브패키지(quad-roblox/quad-gtk/quad-lang/quad-gen/quad-compat/ quad-debug/quad-docs)는 전부 파일이 0개인 빈 디렉토리 — quad-core 밖엔 참고할 게 없음.
  • Store:Pipe/Store:Value 연동이 담긴 유일한 두 예제 콜사이트(slot.luau:31-41)조차 존재하지 않는 Store:Value 메서드를 호출하는 등 실제로 동작 검증된 적이 없는 죽은 스크래치 코드였음 — 이 프로토타입은 끝까지 실사용 검증을 통과한 적이 없음.

건질 만한 것 (인체공학/아이디어만, 코드는 아님):

  • store:Pipe(key):Compute(fn) 같은 왼쪽에서 오른쪽으로 읽히는 파이프 문법 자체는 목표로 유지할 가치가 있음.
  • Pipe가 mutate-vs-fromState 긴장 관계에 제시했던 절충안 — "체이닝된 Compute/Add/... 호출은 자신이 액션 리스트의 유일한 '끝(tip)'일 때만 공유 배열에 그대로 append(뮤테이션), 이미 다른 코드가 그 지점 이후로 체인을 확장해버렸다면 배열을 복사한 뒤 새 Pipe 객체를 반환"하는 copy-on-write 방식 — 한때는 이 문서의 "mutate-in-place vs fromState" 긴장을 풀어보려 한 유일한 시도로서 다시 설계해볼 후보였으나, 아래 "종합"에서 최종적으로 폐기됨state(state) 조합 모델이 소유권/버전 가드 없이도 같은 문제를 더 간단히 풀어서 이 절충안 자체가 불필요해짐. 원본이 갖고 있던 진짜 결함 (소유권/버전 관리 없이 경쟁 상황에 취약, 테스트/실사용 검증도 없었음)은 기록으로만 남김.
  • Depend(...) 액션 — 계산값에는 관여하지 않고 오직 "이 소스가 바뀌면 다시 계산하라"는 추가 의존성만 등록하는 값-투명(value-transparent) no-op 액션. 작지만 깔끔한 아이디어라 이름 그대로 채택할 만함.
  • 흥미로운 발견: 스크래치 파일(out/asdf)에 남아있던 더 이전 버전의 파이핑 스케치가 정확히 Pipe(store.background):With(store.transparency, globalStore.test):Compute(fn) 모양이었음 — 실제 구현으로 넘어가며 :Depend()+포지셔널 인자로 바뀌었지만, :With(...) 네이밍은 사용자가 이번 라운드에서 다시 요청한 것과 정확히 일치 — 우연이 아니라 원래 지향점이었던 것으로 보임, :With 이름 채택에 힘을 실어줌.

종합: 이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — Depend/:With 네이밍은 quad-v2 설계에 그대로 살려볼 가치가 있는 아이디어로 남지만, Pipe의 copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기 쪽으로 재평가됨 (위 "Store/State/Source 온톨로지" 절 참고 — State 자체가 파이핑 결합체이고 state(state)로 분기하는 쪽이 더 간단하다는 사용자의 최신 판단).

확정된 것 (더 이상 열린 질문 아님)

  • 핸들러 계약: isHandlable(inst,k,v) + priority + process(구 bind) + retract(구 cleanup) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장).
  • Signal 클래스: 안 만듦, 콜백 + Connected 계산 속성만(base/ lifecycle-pattern.md).
  • Ref: 도입 확정(위 절 참고), 용도는 "id 기반 조회 대체"가 아니라 "외부 관리 instance를 점진적으로 다루기 위한 직접 참조 획득".

base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입 (2026-08-04 보강)

base/lifecycle-pattern.md가 말하는 "범용 유틸"(per-instance 상태 저장소, 생명 바인드 유틸)은 base가 직접 구현하는 게 아니라 인터페이스만 정의inst는 base 입장에선 any일 수 있음(다른 엔진일 수도 있으므로). 실제 구현은 RobloxFactory(BaseModule) 같은 팩토리 함수가 BaseModule을 뮤테이션해서 그 안에 실 구현체(canExecute 등)를 채워넣는 방식 — 사용자는 quad-base/quad-roblox를 각각 import해서 const quad = RobloxFactory(QuadBase) 세 줄 정도로 직접 조립하면 됨(별도 번들 quad 패키지로 재수출할 필요 없음, 필요하면 만들어도 됨).

확정(2026-08-04 3차 라운드): RobloxFactory를 같은 BaseModule에 여러 번 호출했을 때 — 같은 팩토리로 재호출하면 무시(no-op), hot-reload처럼 초기화 스크립트가 다시 도는 경우를 안전하게 만듦. 다른 팩토리 (AnotherFactory 등, 가상의 예)로 재호출하면 에러 — 이건 base/module-lifecycle-plan.md의 "bind는 유일 슬롯" 원칙(이미 구현체가 있는데 또 다른 구현체로 init하려 하면 오류)이 다루던 것과 정확히 같은 케이스, 이 문서의 이전 "무시" 잠정안과 그 문서의 "오류" 잠정안이 서로 모순되는 게 아니라 같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는 서로 다른 케이스를 각각 가리키고 있었음. 구현은 모듈 테이블에 "누가 초기화했는지" 마커(_initializedBy = "roblox"류, 정확한 이름은 구현 단계)만 두면 됨. 모듈 스코핑(New(), base/architecture.md 13번)과의 관계도 실은 열려있던 게 아니라 자연히 풀림 — New()가 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요.

인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨)

Quad "Frame"처럼 문자열로 인스턴스 종류를 지정하는 방식은 타입 추론이 어려움(위 온톨로지 절의 Luau 오버로드 문제와 같은 원인). 사용자가 실제 참고 코드를 .claude/initreq/artworks/DeclarativeProgramming/ DeclarativeInstance.luau(PA님 작성, UI 포함 전반적 설계 패턴을 시범 적용한 데모 모듈)에 공유해줘서 직접 확인 — "DI"는 Dependency Injection이 아니라 "Declarative Instance"(선언형 인스턴스 생성).

인스턴스 생성 — PA님 코드 그대로 채택: 처음 제안했던 "필드=1급 타입 경로, 문자열=폴백"이라는 2트랙(DI.Frame vs DI.New<<Frame>> "Frame") 구상 보다 실제로는 더 단순했음(DeclarativeInstance.luau:104-160) — 제네릭 생성자 함수 하나(new<ClassName>(className): from<index<UIInstances, ClassName>>)가 알려진 타입과 모르는 타입을 전부 커버하고, 그중 UI에서 자주 쓰는 클래스 ~25개(Frame/TextButton/UICorner 등, UIInstances 타입 테이블에 등록된 것들)만 모듈 로드 시점에 즉시(eager) constructor.Frame = new("Frame")처럼 필드로 미리 채워둠 — __index 메타메소드 지연 생성이 아니라 그냥 정적 테이블. quad-v2도 이 모양 그대로 채택: 하나의 제네릭 생성자 + 자주 쓰는 것만 정적으로 미리 바인딩.

이벤트 바인딩 — On.EventName 도트액세스 안 씀, PA님 방식(평범한 문자열 키 + 런타임 리플렉션)으로 전환: DeclarativeInstance.luau:13-91assign(instance, key, value)ReflectionService:GetPropertiesOfClass/ GetEventsOfClass로 클래스별 프로퍼티/이벤트 타입을 캐싱해두고, 키가 RBXScriptSignal 타입이면 자동으로 instance[key]:Connect(value)로 처리함 — Frame { MouseButton1Click = fn }처럼 별도 네임스페이스 없이 그냥 문자열 키로 씀. 이건 타입 안전성을 어느 정도 포기하는 대가지만(콜백 시그니처까지 Luau가 검증 못 함 — apply<T,U>(instance: T, properties: U): T & U가 스키마 검증 없이 구조적으로만 merge), 이미 UB로 남긴 "테이블 리터럴 안 키별 값 타입 자동 검증 불가"와 같은 급의 한계라 손해가 크지 않고, On. 접두어 없이 문법이 더 간결해짐 — 사용자 확정("PA 님 방식 괜찮은듯. 타이핑은 인라인이 되긴 하겠지 정도면 괜찮다"). quad-v2 구현에서는 이 "키가 이벤트인가" 판별을 isHandlable로 감싼 pluggable 핸들러(quad-robloxReflection Service 기반으로 구현)로 두면 됨 — 별도 On 모듈/필드 접근 구조 자체가 불필요해짐.

Store 쪽 dot-access는 그대로 유지: store.key(1급 타입 경로)/ store "key"(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가 타입을 좁혀주는 이득이 있어서(Store 자체가 {key: Source<number>, ...}류 평범한 레코드 타입으로 지어짐[정정: 2026-08-06 후속 세션에서 필드 타입이 State<T>Source<T>로 갱신, "Source가 State를 만족함" 절 참고]) 그대로 유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가 깨진 건 아님.

PA님 코드와 대조해서 재확인한 것(변경 없음):

  • OOP 회피 결정은 오히려 보강됨 — PA님의 ObjectOrientedProgramming/ class.luausetmetatable(methods, {__index = parent}) 체이닝 상속이라 quad-v2가 피하기로 한 quad2-try Base:Extends와 같은 모양이고, 제네릭을 파일마다 중첩해서 재선언해야 하는 보일러플레이트까지 동일하게 나타남.
  • Instance 태그는 CollectionService 직접 사용 그대로 유지 — PA님의 EventDrivenProgramming/Observer.luausubscribeTaggedInstance도 얇은 CollectionService 래퍼일 뿐. DataOrientedProgramming/TagService.luau는 이것과 무관하게 plain-table 엔티티(비-Instance 데이터)용 커스텀 태그 인덱스라 지금 quad-v2 스코프 밖 — Instance가 아닌 데이터에 태깅이 필요해질 미래 시나리오를 위한 참고 자료로만 기록.
  • Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지 (위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고).

Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션)

base/tag-plan.md/base/attribute-plan.md로 이동 — 이 절이 다루던 타입 파라미터화 문제([Attribute<<boolean>> "name"] vs [BooleanAttribute "name"])뿐 아니라 None/process/retract 동작까지 확정 반영됨. UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야 한다는 지적으로 분리.

Brand — 런타임 nominal 타입 판별 통합 메커니즘, isState를 일반화 (2026-08-07 여덟 번째 세션)

배경: isState(2026-08-07 다섯 번째 세션 확정, :Peek<<T>>(key): T|State<T>|nil가 돌려주는 raw union을 사용자 코드가 분기하려면 판별 수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부 적용됨 — Observer/Effect/Tag/Attribute/Tween/Blocker/Store/ Source/Slot/None까지, Handler 구현(isHandlable에서 "이 값이 Tween인가/Store인가" 판별)과 사용자 코드 양쪽에서 반복적으로 필요해질 수단이라 isState 하나만 만들고 끝내지 않고 전체를 일관된 메커니즘으로 통합(component-composition-plan.md 4번 절이 이미 "isSource류 판별자로 (isObserver와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐).

구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 사용(문자열 아님).

local Brand = {}
local registry = setmetatable({}, {__mode = "k"})

function Brand.set(x, tag) registry[x] = tag end
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값

-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님
local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag,
      StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, ModifierTag =
      {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}

-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서:
Brand.set(newHandle, ObserverTag)

문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안) — Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 무시할 만하지만, 오타 안전성이 실질적 이득: 태그가 오타난 문자열 리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 되어 헷갈릴 여지가 없음.

isXBrand를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈 — 단순 항등인 경우(isObserver(x) = Brand.get(x) == ObserverTag)와, 상위 관계(subtype)가 있어 집합 멤버십이 필요한 경우(isState)로 갈림:

local function isState(x)
  local t = Brand.get(x)
  return t == StateTag or t == SourceTag  -- Source가 State를 구조적으로 만족
end
local function isSource(x)
  return Brand.get(x) == SourceTag
end

정정 — isSource는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 뒤집음(2026-08-07 여덟 번째 세션). 그때는 "State면 충분한 용도"만 염두에 뒀지만, Source는 State보다 진짜로 더 많은 능력(:Set/:Emit)을 가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는 원천인가"를 알아야 하는 코드는 isState만으론 부족함 — isSource를 별도로 제공, isState는 여전히 {State, Source} 둘 다 통과시킴(상위 개념이니까 당연히). component-composition-plan.md 4번 절이 이미 isSource가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두 문서가 서로 모순돼 있었음). base/modifier-plan.md의 "별도 isSource 불필요" 서술도 같이 정정 대상.

갭 보강 — isRef/isPreRef/isModifier가 태그 목록에서 빠져있던 것 추가(2026-08-07 열 번째 세션). 위 코드 예시가 원래 RefTag/ PreRefTag/ModifierTag를 안 만들어뒀는데, 이 문서 곳곳(PreRef 절의 isPreRef(v), component-composition-plan.mdisModifier(v) 등)이 이미 이 predicate들이 존재한다고 전제하고 써왔음 — 실제로 만들어야 하는 게 맞아서 태그 목록에 추가. isRef/isPreRefisObserver와 같은 단순 항등(isRef(x) = Brand.get(x) == RefTag, isPreRef(x) = Brand.get(x) == PreRefTag) — isState처럼 집합 멤버십이 아님, 즉 isRef(preRefInstance)false. 이게 중요한 이유: PreRef가 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 해서 isRefPreRef도 통과시키면, 일반 (v=Ref) 매치 핸들러가 PreRef 인스턴스도 집어삼켜버려 위 "PreRef" 절이 요구하는 "일반 Ref 경로를 절대 타면 안 됨"이 깨짐 — Ref/PreRef는 State/Source 같은 상하위 관계가 아니라 서로 배타적인 형제 브랜드. isModifier도 같은 단순 항등 (Brand.get(x) == ModifierTag).

None은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분. Observer/Store처럼 인스턴스가 여러 개 생기는 타입과 달리 None은 quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 x == None 레퍼런스 비교가 더 싸고 정확함. 다만 Brand.get(x)가 "quad가 아는 모든 값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가 "이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 None도 빠지면 안 되므로, Brand.get이 내부적으로 x == None을 먼저 확인하는 특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — isNone은 바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음).

duck-typing(예: type(x) == "table" and x.Compute ~= nil)을 쓰지 않는 이유: Peek가 돌려주는 T는 Modifier 필드에 들어갈 수 있는 임의의 값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를 가진 T에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키 인덱싱 자체에서 에러를 던지므로 duck-typing이 pcall로 감싸야 하는 지저분한 엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. weak-key 레지스트리는 rbvm 네임스페이스 추적(base/lifecycle-pattern.md)과 같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이 GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 레지스트리인 Observer의 :Subscribe 레지스트리와는 반대 성격).

Luau 타입 narrowing은 자동으로 안 됨 — 명시적 :: 캐스팅 필요(사용자 확인, Luau가 원래 그렇게 동작함). isX(v)가 참이어도 Luau 컴파일러가 v의 정적 타입을 알아서 좁혀주진 않음(TypeScript의 x is T류 사용자 정의 타입 가드를 Luau가 지원 안 함) — if isState(v) then local s = v :: State<any> ... end처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 게 실제 패턴. 여전히 duck-typing/pcall보다 훨씬 안전하니 가치는 있음, 다만 "자동 narrowing"을 기대하면 안 됨.

이름은 전부 가칭 — Brand/ObserverTag류 포함 용어 정리 대상, .claude/question.md에 반영.

남은 열린 질문 (.claude/question.md에도 취합)

이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/:Compute/State 쓰기 금지/Slot 생존 확인 → dot-access 타입 추론/인스턴스·이벤트 네이밍/ RobloxFactory 재호출 가드)를 거치며 전부 확정됨. 남은 건 순수 API 표면 이름뿐:

  • state()/Source()/Get()/DI(또는 다른 이름) 등 정확한 함수·생성자· 모듈 이름 — 방향은 전부 확정, 이름만 구현 단계에서 남음(On 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고).
  • process() 호출마다 우선순위 스캔 비용 — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상).

해소된 것: "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 필요한가"는 재검토 결과 질문 자체가 성립 안 함으로 결론 — 두 가지 독립적인 이유로 이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 "Store가 Store를 저장 가능한가" 절, 2026-08-04 6차 — Store는 Source에 준하는 "시작점" 이라 다른 반응형 값을 담아 자동 연결되는 용도로 안 씀). (2) 설령 발생해도 State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적 dispose() 호출이 아예 없음, base/lifecycle-pattern.md의 GC 위임 원칙 재사용)라 "같은 걸 두 번 해제"할 행위 자체가 존재하지 않음(GC는 멱등). ":Compute가 with한 값을 어떻게 읽는가"/"emit 필요 여부"도 전파 모델 확정으로 해소, RobloxFactory 중복 호출/충돌 시나리오·인스턴스 생성/이벤트 네이밍도 위 절에서 전부 확정.