콘텐츠로 이동

디스패치 핸들러 계약

q.Elem "Frame" { ... } 같은 리터럴이 실제 요소를 만들 때, quad는 props의 자리 하나하나를 디스패치 엔진에 넘깁니다. 엔진은 등록된 핸들러들을 우선순위대로 훑어 그 (요소, 키, 값) 조합을 처리할 첫 핸들러를 찾고, 그 핸들러가 돌려준 retractor를 그 자리에 보관합니다. 나중에 같은 자리에 다른 값이 오면 보관해둔 retractor로 이전 상태를 무릅니다.

이 페이지는 핸들러를 직접 작성하는 사람을 위한 계약입니다. 새 값 타입(백엔드의 Tween, 플러그인의 스프링 값)이나 새 props 어휘를 추가하려면 여기 규약을 지키는 테이블 하나를 q.Dispatch.addHandler로 등록하면 됩니다.

이 페이지의 심볼: Handler 레코드 · retractor · 우선순위 밴드 · q.Dispatch.addHandler(handler) · q.Dispatch.listHandlers() · q.Dispatch.getHandler(inst, key, value) · q.Dispatch.process(inst, key, value, index) · q.Dispatch.retractFrom(inst, key, index) · q.Dispatch.drive(inst, flattened) · q.Protocols.DispatchHint · q.Bookkeeping.setLength(ownerKey, i, len, anchor, element) · q.Bookkeeping.setOffsetSource(ownerKey, i, source) · q.Bookkeeping.setEmpty(ownerKey, i, anchor) · q.Bookkeeping.getOffsetAt(ownerKey, at) · q.Bookkeeping.getBookkeeping(ownerKey) · q.Bookkeeping.claimOwnerAt(element, inst, k) · q.Bookkeeping.releaseOwner(element, ownerKey)

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")

q.Dispatch는 모듈 인스턴스마다 따로 있는 네임스페이스입니다 — 핸들러 레지스트리와 부기는 인스턴스별 상태라, 한 인스턴스에 등록한 핸들러는 다른 인스턴스에서 보이지 않습니다.

시그니처

export type Handler = {
name: string?,
keyType: ("number" | "string")?,
debugOnly: boolean?,
isHandlable: (inst: any, key: any, value: any) -> boolean,
priority: number,
process: (inst: any, key: any, value: any, index: number) -> (nextValue: any?, retracting: boolean) -> (),
}
필드 필수 설명
isHandlable ✅ 순수 판별. 이 (요소, 키, 값)을 내가 처리할 수 있는가. 부작용을 넣지 마세요 — 매치되지 않는 스캔에서도 계속 불립니다. 예외는 하나 — debugOnly 핸들러는 진단 경고를 찍을 수 있습니다(아래 debugOnly 행).
priority ✅ 스캔 순서. 큰 값이 먼저입니다. 아래 밴드 상수에서 출발해 ± n으로 미세 조정합니다.
process ✅ 실제 처리. 반환값은 retractor 함수이며 생략할 수 없습니다 — 무를 것이 없어도 q.Void를 돌려줍니다. 함수가 아닌 값(true·숫자·테이블, __call 테이블 포함)은 계약 위반입니다 — 평소에는 nil만 그 자리에서 검사하고(매 process마다 도는 경로라), q.debug를 켜면 나머지도 그 자리에서 거부합니다. 끈 채로 두면 나중에 그 자리를 무를 때 quad 안에서 번호 없는 에러로 죽습니다.
name — 진단 전용. 동률 경고와 listHandlers 덤프에 쓰입니다. 스캔·매치에는 아무 영향이 없고, 없으면 priority만 보입니다.
keyType — “나는 이 타입의 키에서만 매치될 수 있다”는 선언. 스캔 버킷을 좁히는 힌트입니다.
debugOnly — true면 q.debug가 참인 스캔에서만 후보가 됩니다. q.debug가 꺼져 있으면 에러가 아니라 후보에서 조용히 빠집니다 — 그 키는 이 핸들러가 없는 것처럼 스캔됩니다. q.debug는 스캔할 때마다 읽으므로 켜고 끄면 다음 스캔부터 바로 반영됩니다. keyType처럼 등록 때 한 번 읽습니다(나중에 필드를 바꿔도 목록을 옮기지 않음). 쓰임은 경고를 찍고 false를 돌려 스캔을 그대로 흘려보내는 진단 필터가 기본이지만, true를 돌려 debug에서만 키를 맡는 핸들러도 됩니다. true/false/nil 말고는 등록 때 거부합니다(Quad0266). debug를 꺼 두면 스캔 비용은 분기 하나뿐이고, debugOnly 핸들러를 등록해도 나머지 핸들러끼리의 순서(동률 포함)는 바뀌지 않습니다.

process의 index는 그 자리의 체인 깊이입니다. 값을 한 겹 벗겨 아래로 위임하는 핸들러(래핑 핸들러)는 q.Dispatch.process(inst, key, 벗긴값, index + 1)을 부릅니다 — 그러면 같은 자리에 핸들러가 층으로 쌓이고, 무를 때는 깊은 층부터 역순으로 풀립니다.

keyType은 isHandlable을 대신하지 않습니다. 최종 판별자는 여전히 isHandlable이고, 선언은 그 술어가 어차피 거부할 키를 아예 묻지 않게 할 뿐입니다. 그래서 둘이 어긋나게 선언하면 조용히 그 버킷에서 빠지고, 더 낮은 우선순위의 미선언 핸들러가 대신 매치될 수 있습니다 — 선언 값은 isHandlable의 type(key) 가드와 같아야 합니다. 선언하지 않으면 모든 버킷에 듭니다.

시그니처

(nextValue: any?, retracting: boolean) -> ()

process가 돌려주는 함수입니다. 그 자리를 무를 때 엔진이 부릅니다.

이름 설명
nextValue 같은 핸들러가 곧 처리할 새 값. 단순 철거일 때는 nil.
retracting true면 단순 철거 — 이 자리는 다시 처리되지 않습니다. false면 같은 핸들러의 재처리가 곧 뒤따릅니다.

두 번째 인자가 존재하는 이유는 하나입니다: nil을 값으로 받을 수 있는 핸들러가 “철거의 nil”과 “값인 nil”을 구별할 수 있어야 하기 때문입니다. retracting을 보지 않고 nextValue == nil만 보면 둘을 섞습니다.

nextValue는 힌트입니다 — retractor는 자기 철거의 기준을 정할 뿐(무엇을 남기고 무엇을 지울지), 새 값이 놓일 수 있는지(claim 여부·자리 점유·순환)를 미리 묻지 않습니다. 그 판정은 곧 뒤따르는 process의 몫이고, 거기서 거부되면 철거는 이미 끝난 뒤라 자리가 빈 채 남습니다(사용자 표면의 서술은 roblox/02 “숫자 키” 절). retractor에 그 검사를 넣지 마세요 — 같은 판정을 두 자리에 적게 됩니다.

같은 핸들러가 같은 자리를 계속 맡을 때는 아래 층을 건드리지 않습니다 — 앉아 있던 retractor에게 새 값을 넘겨 자기 전이를 하게 한 뒤, 그 한 층만 새 retractor로 교체합니다(그래서 위임하는 래퍼는 어느 분기에서도 반드시 다시 위임해야 합니다 — 한 번 위임했다가 이번엔 위임하지 않기로 하면 깊은 층은 최종 철거까지 살아 남아 옛 값에 대한 구독과 부작용이 계속 돕니다(고아 — 버그). 그 층을 정말 걷어내야 하면 q.Dispatch.retractFrom(inst, k, index + 1)을 직접 부르세요; 내장 래퍼는 늘 위임하므로 해당 없음 — [2026-09-27 mock 실측]). 핸들러가 바뀌면 그 층과 그 아래를 전부 무르고(깊은 쪽부터) 새로 설치합니다.

관측되는 호출 순서 — Note 키에 값을 두 번 넣고 마지막에 철거하면:

시점 호출
첫 값 process(inst, "Note", 값1, 1)
둘째 값 retractor(값2, false) → process(inst, "Note", 값2, 1)
철거 retractor(nil, true)

시그니처

HANDLER_PRIORITY_HIGH: number
HANDLER_PRIORITY_NORMAL: number
HANDLER_PRIORITY_LOW: number
HANDLER_PRIORITY_FALLBACK: number
상수 값 쓰임
q.Dispatch.HANDLER_PRIORITY_HIGH 1000 센티널·래핑처럼 다른 어떤 해석보다 먼저 봐야 하는 것
q.Dispatch.HANDLER_PRIORITY_NORMAL 0 보통의 값 핸들러
q.Dispatch.HANDLER_PRIORITY_LOW -1000 더 구체적인 핸들러에 양보하는 것
q.Dispatch.HANDLER_PRIORITY_FALLBACK -1000000 아무도 못 잡았을 때의 마지막 그물

우선순위 공간은 열린 숫자입니다 — 계약은 밴드의 순서와 의미이고, 리터럴 값 자체는 계약이 아닙니다. HANDLER_PRIORITY_NORMAL + 1처럼 밴드에서 오프셋해 쓰는 것이 의도된 사용법입니다.

동률에는 정의된 순서가 없습니다 — 같은 등록 순서면 매번 같지만(정렬이 결정적) 등록 순서가 이기는 것도 아니고, 무관한 priority의 핸들러가 하나 더 등록될 때마다 기존 동률의 순서가 다시 섞일 수 있습니다(나중에 붙은 플러그인 하나가 기존 두 핸들러의 승자를 조용히 바꿀 수 있음 — [2026-09-27 mock 실측]). q.debug가 참이면 등록 시점에 진단 줄이 출력됩니다(둘째 핸들러가 등록되는 순간 첫 동률 상대 하나만 — 이후 재배열은 알리지 않고, 등록 뒤에 켜면 0줄)(같은 버킷을 두고 경쟁할 수 있는 핸들러들 사이에서만 — 서로 다른 keyType을 선언한 둘은 애초에 같은 버킷에 없으므로 동률로 치지 않습니다). debugOnly 핸들러는 평소 핸들러와 따로 정렬되고 debug 스캔에서만 안정 병합되므로(동률이면 debugOnly 쪽이 먼저), 그것과의 동률은 debug에서만 의미가 있습니다.

HANDLER_PRIORITY_NORMAL + 2에는 quad가 등록한 debugOnly 진단 필터가 있습니다. 배열 원소로만 쓰이는 quad 값(Ref 가족·Observer·Effect·Slot·Tag·Attr)이 문자 키에 놓이면, q.debug일 때 그 자리에서 경고 한 줄을 찍고(같은 요소·키는 한 번) 스캔을 그대로 흘려보냅니다 — 그 아래(NORMAL의 Property, NORMAL + 1의 인스턴스 축약)가 받는 동작은 바뀌지 않습니다. 그래서 NORMAL + 1 이하에서 그런 값을 문자 키로 일부러 받는 프로바이더 핸들러는 debug에서 이 경고를 봅니다 — 경고 없이 받으려면 NORMAL + 3 이상에 등록하세요.

시그니처

addHandler: (handler: Handler) -> ()

동작 — 핸들러를 이 모듈 인스턴스의 레지스트리에 등록합니다. 레지스트리는 항상 우선순위 내림차순으로 정렬돼 있고, 키 타입별 스캔 목록이 등록 때마다 다시 만들어집니다. 받은 isHandlable/process는 그 자리에서 에러 태그 층에 등록됩니다 — 그래야 프로바이더의 프레임이 사용자 코드가 아니라 quad 기계로 보이고, blame이 사용자 줄에 닿습니다.

모양 검사가 먼저 돕니다.

Quad0079 Dispatch.addHandler: handler must be a table with isHandlable/process functions and a numeric priority (got {typeof})
Quad0080 Dispatch.addHandler: priority must be a finite number (got {tostring(pr)})
Quad0081 Dispatch.addHandler: keyType must be "number", "string" or nil (got {값})
Quad0266 Dispatch.addHandler: debugOnly must be a boolean or nil (got {typeof})

시그니처

listHandlers: () -> { Handler }

반환 — 등록된 핸들러 전부를 우선순위 순으로 담은 사본(table.clone). debugOnly 핸들러도 q.debug와 무관하게 늘 들어 있습니다(각 항목의 debugOnly 필드로 가릴 수 있음). [2026-09-26 기준] 사본인 건 그 배열뿐입니다 — 안의 핸들러 테이블은 레지스트리에 실제로 앉은 항목 그 자체입니다. 읽기만 하세요: 예를 들어 돌려받은 항목의 isHandlable을 갈아 끼우면 그 자리에서 레지스트리의 핸들러가 바뀌고, 이후 그 우선순위로 오는 모든 매치에 영향을 줍니다(q.Dispatch.getHandler 사본도 같습니다).

동작 — 순수 조회입니다. 아무것도 출력하지 않습니다 — 보여줄지 말지는 부르는 쪽의 일입니다.

시그니처

getHandler: (inst: any, key: any, value: any) -> Handler?

반환 — 우선순위 순으로 isHandlable이 참을 준 첫 핸들러, 없으면 nil.

동작 — 순수 스캔입니다. process/retract를 부르지 않고 부기도 건드리지 않습니다. 단 q.debug가 참이면 debugOnly 핸들러도 훑으므로 그 진단 필터가 경고를 찍을 수 있습니다. 매치 실패를 에러로 만드는 것은 process의 일이라, 여기서는 조용히 nil입니다. 키의 type에 맞는 버킷만 훑습니다.

인자 자체가 틀리면(inst가 nil, key가 nil) 매치 실패와는 다른 층 — process/retractFrom과 같은 검사(checkInst/checkKey)로 그 자리에서 즉시 던집니다(Quad0072/Quad0074). getHandler는 process/retractFrom 앞에 서는 다른 진입점이 없어, 이 검사가 없으면 nil/nil 인자가 그대로 isHandlable에 들어가 잎 모듈(예: quad-roblox의 Reflection)의 raw VM 에러가 됩니다. [2026-09-26 기준] inst가 nil이 아니지만 그 백엔드의 Instance 모양이 아닌 값(예: 빈 테이블)은 이 검사를 통과합니다 — 그 경우 isHandlable이 여전히 잎 모듈 에러를 낼 수 있고, q.Dispatch는 백엔드 무관 설계라 여기서 인스턴스 모양을 검증하지 않습니다.

q.Dispatch.process(inst, key, value, index)

섹션 제목: “q.Dispatch.process(inst, key, value, index)”

시그니처

process: (inst: any, key: any, value: any, index: number) -> ()
이름 타입 설명
inst any 대상 요소. nil이면 에러
key any props의 키(숫자 키 자리면 숫자, 문자 키면 문자열 등)
value any 그 자리의 값
index number 체인 깊이. 최초 진입은 1, 위임은 index + 1

동작 — 그 자리에 값을 설치합니다. 위 retractor 절의 전이 규칙이 여기서 실행됩니다. 체인 리스트는 process 본문이 핸들러를 부르기 전에 등록됩니다 — 그래야 핸들러가 재귀적으로 자기 자신을 다시 부를 때 같은 리스트에 층을 쌓습니다.

매치되는 핸들러가 없으면 즉시 던지고, 메시지는 사용자의 진입 줄까지 올라갑니다. 값이 q.BrandPredicates에 등록된 술어에 걸리면 걸린 브랜드 이름을 전부(이름순) 같이 싣습니다.

Quad0076 Dispatch: no handler matched key {키} (value: {typeof}, brand: {브랜드 이름들}) - this quad value has no handler at an array position

꼬리 힌트는 키와 값에 따라 갈립니다. 값이 q.Protocols.DispatchHint에 속하면 그 값이 직접 원인 문장을 줍니다(Store·Blocker·Tween 등 — 아래 절). 그 밖에는 위처럼 숫자 키에 등록된 브랜드의 값이 온 경우의 일반 꼬리입니다(등록됐다는 것이 그 브랜드의 주인이 설치됐다는 뜻이라, quad-base 값이든 설치된 프로바이더의 값이든 같습니다). 등록된 술어에 하나도 걸리지 않는 값이면 숫자 키에선 - check that the provider for this value (e.g. quad-roblox) is initialized(값이 함수면 대신 호출을 빠뜨린 생성자·컴포넌트인지 묻는 꼬리), 문자 키에선 키 이름이 틀렸을 가능성과 프로바이더 미설치를 함께 적습니다. 전체 갈래는 Quad0076.

Slot은 양의 정수 키 자리에만 놓입니다. 내장 Slot 핸들러의 조건이 “키가 1 이상의 정수이고 값이 Slot“이라, process를 직접 불러 Slot을 0·음수·소수 키에 놓으면 위 Quad0076(brand: Slot)으로 거부됩니다 — 문자 키의 Slot과 같은 거부이고 꼬리 문장만 다릅니다. q.Elem "<Class>" { … }의 배열부는 늘 1부터의 정수 키라 사용자 코드는 닿지 않습니다.

값이 nil이면 뒤에 한 줄이 더 붙습니다 — 그 nil은 벗겨진 None일 수도, 반응형 nil일 수도 있고, 그 키를 맡은 핸들러가 nil을 받아들여야 한다는 안내입니다.

핸들러가 retractor 함수를 돌려주지 않으면 프로바이더 계약 위반입니다. nil은 늘 그 자리에서, 함수가 아닌 다른 값은 q.debug가 켜져 있을 때만 그 자리에서 거부합니다.

Quad0077 Dispatch: handler "{이름}" (priority {n}) must return a retractor function at key {키}, index {i} (got {typeof}) - return Void when there is nothing to undo

inst가 nil이면 Quad0072 Dispatch.process: inst must not be nil, index가 양의 정수가 아니면 Quad0073 Dispatch.process: index must be a positive integer (got {tostring(index)}).

시그니처

retractFrom: (inst: any, key: any, index: number) -> ()

동작 — index(포함)부터 그 자리 체인의 꼬리까지를 깊은 쪽부터 무릅니다. 얕은 층이 깊은 층을 만들었으므로 역순이 맞습니다. 각 retractor는 (nil, true)로 불립니다 — 단순 철거이고 뒤따르는 재처리가 없다는 뜻입니다.

체인이 비면 그 자리의 체인 기록이 놓입니다(Length/Offset 부기는 핸들러가 setLength(…, 0) 경로로 직접 풀어야 합니다). inst가 nil이면 Quad0072 Dispatch.retractFrom: inst must not be nil, index가 양의 정수가 아니면 Quad0073 Dispatch.retractFrom: index must be a positive integer (got {tostring(index)}).

시그니처

drive: (inst: any, flattened: { [any]: any }) -> ()

동작 — props 테이블 하나를 요소에 통째로 적용하는 파이프라인입니다. 백엔드의 생성 경로(q.Elem "Class" { ... })와 Claim이 같은 함수를 부릅니다. 이미 q.Elem이 만든 요소에 다시 drive(inst, { a, b })를 부르면 숫자 키 자리 1, 2, …를 다시 처리합니다 — 거기 앉아 있던 것은 파괴가 아니라 철거됩니다(Effect는 그 자리에서 cleanup(false), OnDestroyed는 조용히 풀려 영영 안 불림; 위 자리 교체 규칙 그대로 — [2026-09-27 mock 실측]). 자식을 덧붙이려면 처음부터 선언한 Slot에 Add하세요. 순서:

  1. flatten — 먼저 문자 키 부분을 한 번 훑으며 숫자 키 자리 도메인(양의 정수 — 어떤 핸들러가 부기를 만지기 전에)과 문자 키의 Modifier를 검사하고, 숫자 키 자리의 Modifier를 그 자리에서 소비해 그 필드들을 문자 키 자리에 병합합니다. 배열 길이는 바뀌지 않습니다.
  2. 배치 게이트 열기 — 숫자 키가 있을 때만 그 요소의 배치 Blocker를 켭니다(자식이 없는 요소가 괜히 부기를 키우지 않도록).
  3. PreRef 사전 통과 — 숫자 키의 PreRef가 여기서 발화하고, PostRef는 수집됩니다. 소비된 자리는 센티널로 바뀌어 배열에 구멍이 생기지 않습니다.
  4. 본문 루프 — 모든 자리에 대해 process(inst, key, value, 1).
  5. PostRef 발화 — 본문 루프 뒤, 배치가 닫히기 전에. 그래서 콜백 안에서 한 slot:Add 같은 변경도 같은 배치에 흡수됩니다.
  6. 배치 닫기 — 게이트를 닫고 정확히 한 번 재계산합니다.

입력 게이트:

Quad0082 Dispatch.drive: props must be a table (got {typeof})
Quad0083 Dispatch.drive: props must be a plain { ... } table - a quad value needs the braces (got {브랜드 이름들})
Quad0084 Dispatch.drive: inst must not be nil
Quad0085 Dispatch.drive: array keys must be positive integers (got {키})

둘째 문구는 q.Elem "Frame" (q.Source(1))처럼 중괄호를 잊은 형태를 잡습니다 — 그 값의 내부 필드가 props로 해석되는 조용한 오작동을 막습니다. 매 drive마다 도는 검사는 싼 것 둘뿐입니다 — 메타테이블이 있는 테이블, 그리고 메타테이블 없이 quad가 브랜드를 붙이는 AttrKey·매퍼 디스크립터. quad-base의 값은 전부 이 둘에 걸립니다. 모듈에 등록된 브랜드 술어(q.BrandPredicates — 설치된 프로바이더의 것까지) 전부를 훑는 전체 검사는 q.debug가 켜져 있을 때만 돕니다 — 그래서 메타테이블 없는 평범한 테이블에 브랜드를 붙이는 프로바이더 값은 debug를 꺼 두면 이 문구 대신 그 필드들이 props로 해석됩니다(대개 Quad0076으로 끝납니다).

시그니처

Protocols: { [string]: Brand? } -- 지금 키는 DispatchHint 하나 — QuadTypes.Brand() 인스턴스, quad-base 사본마다 하나(모든 모듈 인스턴스의 Protocols가 같은 객체를 담음)
-- 멤버 값이 갖는 훅:
-- value:__dispatchHint(key: any) -> string?

동작 — 디스패치의 매치 실패 진단을 돕는 프로토콜입니다. 이 브랜드에 속한 값이 어떤 핸들러에도 매치되지 않는 자리(key)에 놓이면(Quad0076), 디스패치는 그 값의 __dispatchHint(key)를 한 번 불러 돌려받은 문장을 에러의 원인 설명(- 뒤)으로 씁니다. nil을 돌려주면 일반 꼬리를 씁니다. 에러의 위치(blame)는 바꾸지 않습니다 — 문장만 공급합니다. 에러를 만들 때만 불리므로 정상 경로 비용은 없습니다.

  • 값 쪽에서 등록합니다 — 값을 만들 때 quad.Protocols.DispatchHint:Register(value)(타입은 Brand?라 한 번 nil 확인으로 좁힙니다 — 아래 예제), 그리고 그 값(대개 메타테이블의 __index 쪽)에 __dispatchHint를 둡니다. 훅이 아니라 소속이 판정이라, 같은 이름의 메서드가 있어도 등록되지 않은 값에는 묻지 않습니다. quad-base의 Store·Context·Provider·Blocker·AttrKey와 quad-roblox의 Tween이 자기 문장을 등록합니다(문장은 Quad0076의 표).
  • 훅이 던지면 정의되지 않은 동작입니다 — 디스패치는 훅을 감싸지 않습니다. 돌려주는 값은 문장 하나(string) 또는 nil입니다. 문장 모양은 quad의 메시지 규약을 따르면 다른 꼬리와 어울립니다 — 소문자로 시작하고, 무엇이 틀렸는지와 어떻게 놓아야 하는지를 한 문장으로.
  • key를 보고 갈라도 됩니다 — Tween은 문자 키에서만 “그 요소의 필드가 아닐 수 있음”을 말하고 숫자 키에서는 nil을 돌려 일반 꼬리에 맡깁니다.
  • q.BrandPredicates와는 다른 집합입니다 — 그쪽은 값에 이름을 붙이는 is<Brand> 술어의 등록부이고(메시지의 brand: …), Protocols는 quad 런타임이 부르는 훅을 가진 값의 능력(프로토콜) 브랜드를 모읍니다. 앞으로 다른 능력이 공개되면 이 테이블에 키가 더해집니다(키 추가는 호환을 깨지 않습니다). 타입이 런타임과 같은 모양({ [string]: Brand? })인 것도 그래서입니다 — 키가 늘어도 타입은 그대로이고, 읽는 쪽은 nil 확인으로 좁힙니다(훅 __dispatchHint는 타입에 싣지 않습니다).
  • 이름 규약 — 두 밑줄로 시작하는 이름(__dispatchHint, __apply)은 quad 런타임이 부르는 공개 프로토콜 훅이고 계약입니다(Luau의 메타메서드처럼). 밑줄 하나(_x)는 비공개 내부 필드라 계약이 아닙니다.
  • 브랜드는 quad-base 사본마다 하나입니다 — 같은 사본에서 만든 모듈 인스턴스들은 같은 DispatchHint 객체를 공유하고, 공유 잎 모듈(Blocker·Context·AttrKey)의 값도 그래서 등록됩니다. 다른 사본의 값은 이 사본의 브랜드에 속하지 않습니다(사본 경계).

예제

local SpringImpl = {}
SpringImpl.__index = SpringImpl
function SpringImpl:__dispatchHint(key)
if type(key) == "string" then
return "a Spring drives a value: put it inside a State (state:Apply(spring)), not at a property key"
end
return nil -- 숫자 키: 일반 꼬리
end
local DispatchHint = quad.Protocols.DispatchHint -- Brand? — 한 번 좁혀 둔다
local function Spring(opts)
local self = setmetatable(table.clone(opts), SpringImpl)
if DispatchHint then
DispatchHint:Register(self)
end
return self
end

아래 부기 함수들은 말단(leaf) 핸들러 작성자를 위한 표면이고, q.Dispatch가 아니라 q.Bookkeeping 네임스페이스에 삽니다(타입 Bookkeeping). 숫자 키 자리는 물리 트리에서 여러 개의 실제 요소를 차지할 수 있으므로(자식 하나일 수도, Slot이 펼치는 N개일 수도 있다), 엔진은 자리마다 “길이”와 “시작 오프셋”을 부기해 부분합으로 관리합니다.

계약: 숫자 키 자리를 맡은 말단 핸들러는 자리마다 길이와 오프셋 소스를 둘 다 등록해야 합니다. 하나라도 빠뜨리면 그 owner의 오프셋 산술이 깨지고, 나중에 getOffsetAt이 “등록을 건너뛴 핸들러가 있다”고 알려주는 자리에서 터집니다. 해제 순서는 **setOffsetSource(None) 다음 setLength(0)**이고, 그 쌍을 한 번에 하는 함수가 setEmpty입니다. 문자 키 핸들러에는 이 의무가 없습니다.

소유권 계약: 자리에 값을 실제로 놓는(부모를 바꾸는) 핸들러는 그 값의 자리를 등록해야 합니다 — process에서 claimOwnerAt(value, inst, k), retractor에서 releaseOwner(value, inst). 하나의 값은 동시에 한 자리에만 앉을 수 있고, 그 레지스트리는 Slot의 원소·정적 자식·숏핸드가 만드는 관리 자식이 전부 같이 씁니다 — 그래서 어느 경로로든 두 번째 자리는 already mounted elsewhere로 거부되고, 앉아 있는 동안 q.dispose도 거부됩니다. 기준은 키 종류가 아니라 부모를 바꾸는가입니다 — 문자 키 핸들러라도 자식을 만들어 붙이면 등록합니다(숏핸드가 (inst, "_quad_round") 자리로 그렇게 합니다). 값을 놓지 않는 핸들러(setEmpty만 부르는 잎, 프로퍼티·이벤트처럼 값을 쓰기만 하는 핸들러)에는 이 의무가 없습니다. 자기가 잡은 자리만 풉니다 — releaseOwner는 ownerKey가 맞기만 하면 통과하므로, 남의 자리(예: 어떤 Slot의 원소)를 그 Slot을 ownerKey로 넘겨 풀면 그 Slot의 부기가 깨져 다음 CRUD에서 ownership tracking is broken으로 동결됩니다. 두 op 모두 element·inst/ownerKey가 nil이면 즉시 에러이고, claimOwnerAt의 k(자리 키 — 위치 숫자 또는 숏핸드 자식 이름)도 nil이면 에러입니다.

공통 인자 검사는 세 종류이고 함수 이름만 바뀝니다.

Quad0020 Bookkeeping.{함수}: ownerKey must not be nil
Quad0019 Bookkeeping.{함수}: position must be a positive integer (got {tostring(i)})
Quad0021 Bookkeeping.{함수}: length must be a non-negative integer (got {tostring(n)})

q.Bookkeeping.setLength(ownerKey, i, len, anchor, element)

섹션 제목: “q.Bookkeeping.setLength(ownerKey, i, len, anchor, element)”

시그니처

setLength: (ownerKey: any, i: number, len: number | StateMarker<number>, anchor: any?, element: any?) -> ()
이름 타입 설명
ownerKey any 부기의 주인 — 요소이거나 Slot
i number 숫자 키 자리(1부터)
len number | StateMarker<number> 이 자리가 차지하는 물리 요소 수. 상수이거나 State<number>
anchor any? State 길이일 때 구독의 수명을 묶을 대상(물리 Instance). 생략하면 ownerKey — ownerKey가 Slot이면 생략하지 말고 그 Slot이 붙은 물리 Instance를 넘기세요(수명은 Slot이 아니라 실제 인스턴스에 묶입니다 — Slot을 앵커로 두면 Roblox 백엔드에서 Quad0226)
element any? 이 자리에 놓인 요소 — 나중에 자리를 되짚기 위한 역맵에 기록됩니다. 한 요소는 한 owner에서 한 자리에만 놓입니다 — 같은 요소를 두 자리에 넘기면 역맵이 마지막 자리만 기억해 앞 자리 길이 변경이 캐시를 무효화하지 못합니다(계약 위반, 정의되지 않은 동작 — [2026-09-27 mock 실측 — 관측, 계약 아님])

동작 — 자리 i의 길이를 등록합니다. len이 State면 그 상태를 구독해 값이 바뀔 때마다 뒤 자리들의 오프셋을 다시 계산하고, 상수면 같은 게이트를 한 번 통과합니다. 등록은 그 자리 뒤 자리들의 캐시만 무효화합니다 — 자리 i 자신의 오프셋은 앞 자리들의 합이라 바뀌지 않습니다.

State 길이의 값 검사는 두 번 돕니다. 등록할 때 그 State의 현재 값을 한 번 읽어 상수와 같은 검사를 하고 — 부기를 하나라도 쓰기 전에라, 잘못된 초기값(음수 등)은 Quad0021로 던지고 그 자리는 등록되지 않은 채 남습니다(Compute면 이 읽기에서 계산이 한 번 앞당겨 돕니다). 이후 값은 그 상태의 옵서버 안에서 검사합니다 — 재계산 창 안에서 던져 owner를 얼어붙게 만들지 않기 위해서입니다.

q.Bookkeeping.setOffsetSource(ownerKey, i, source)

섹션 제목: “q.Bookkeeping.setOffsetSource(ownerKey, i, source)”

시그니처

setOffsetSource: (ownerKey: any, i: number, source: any) -> () -- Source<number> | None

동작 — 자리 i의 오프셋 발행 채널을 등록합니다. Source<number>를 주면 그 자리의 절대 오프셋이 바뀔 때마다 그 소스에 :Set됩니다(등록 즉시 현재 값과 다르면 한 번 발행). q.None을 주면 “발행 채널 없음”이라는 뜻입니다 — 참여하지 않는다는 뜻이 아닙니다(참여 여부는 길이 쪽이 답합니다). 그런 자리의 오프셋이 필요하면 getOffsetAt으로 당겨옵니다.

Quad0025 Bookkeeping.setOffsetSource: source must be a Source<number> or None (got {typeof})

이 검사는 부기를 한 글자도 쓰기 전에 돕니다 — 잘못된 소스를 절반쯤 기록한 채 실패하면 그 owner의 오프셋 산술이 영구히 얼어붙기 때문입니다.

q.Bookkeeping.setEmpty(ownerKey, i, anchor)

섹션 제목: “q.Bookkeeping.setEmpty(ownerKey, i, anchor)”

시그니처

setEmpty: (ownerKey: any, i: number, anchor: any?) -> ()

동작 — “이 자리는 비어 있다”를 등록하는 관용구 한 줄. 내부적으로 setOffsetSource(ownerKey, i, None) 다음 setLength(ownerKey, i, 0, anchor)를 그 순서대로 부릅니다. 아무것도 마운트하지 않는 말단 핸들러(값이 nil인 자리, 소비된 센티널 자리)가 자기 자리를 순서 산술에서 빼지 않고 등록하는 방법입니다.

시그니처

getOffsetAt: (ownerKey: any, at: number) -> number

반환 — 자리 at의 절대 오프셋(0부터 세는 개수). Slot이 owner면 그 Slot 자신의 .Offset이 출발점이고, 물리 요소가 owner면 0입니다.

동작 — 부분합 캐시를 채우며 계산합니다. 길이가 State면 그 자리에서 사용자 코드(:Get())가 돌 수 있으므로, 그 코드가 아래쪽을 무효화했으면 그 지점부터 다시 채웁니다.

조회할 수 있는 최대 자리는 등록된 마지막 자리 + 1입니다. 그보다 뒤를 묻거나, 중간 자리가 등록되지 않았으면 던집니다.

Quad0022 Bookkeeping.getOffsetAt: position {at} is past N+1 (N = {n}, at most {n+1} may be queried) - or a leaf handler skipped its position registration: position {i} is not registered
Quad0229 Bookkeeping.getOffsetAt: position {at} needs positions 1..{at-1} registered but {i} is not - a leaf handler skipped its position registration

시그니처

getBookkeeping: (ownerKey: any) -> any

반환 — 그 owner의 내부 부기 테이블(없으면 만들어서). 절대 nil을 돌려주지 않으므로 부르는 쪽이 다시 가드할 필요가 없습니다.

반환 타입이 any인 이유는 그 테이블의 필드 구성이 공개 계약이 아니기 때문입니다 — 진단·디버깅용으로 열려 있을 뿐, 필드 모양에 의존하는 코드를 쓰지 마세요.

q.Bookkeeping.claimOwnerAt(element, inst, k)

섹션 제목: “q.Bookkeeping.claimOwnerAt(element, inst, k)”

시그니처

claimOwnerAt: (element: any, inst: any, k: any) -> boolean

동작 — element가 inst의 자리 k에 앉는다고 등록합니다. 다른 자리가 이미 쥔 값이면 던지고, 정확히 같은 자리가 이미 쥐고 있으면 false를 돌려주며 아무것도 하지 않습니다(같은 값 재발행 dedup 뒤 다시 process될 때 — 이때는 물리 부착도 건너뛰면 됩니다). 새로 등록했으면 true. 자리를 등록하는 순서는 부기·물리 부착보다 먼저입니다 — 거부가 나면 아무것도 남지 않아야 하므로.

내장 핸들러가 쓰는 자리 종류는 셋입니다 — Slot의 원소(Slot 자신이 owner), 정적 자식((inst, 숫자 키)), 숏핸드 관리 자식((inst, 자식 이름)). 전부 한 레지스트리라 서로 섞이지 않습니다.

Quad0156 Bookkeeping.claimOwnerAt: this element is already mounted elsewhere - one value takes one seat, so the same element cannot sit in two parent tables, twice in one table, or in two Slots (if its owner was destroyed outside quad - `inst:Destroy()` - the value went with it and cannot be reused after its parent is destroyed; extract it before destroying, as with an Instance)

q.Bookkeeping.releaseOwner(element, ownerKey)

섹션 제목: “q.Bookkeeping.releaseOwner(element, ownerKey)”

시그니처

releaseOwner: (element: any, ownerKey: any) -> ()

동작 — claimOwnerAt으로 등록한 자리를 풉니다. retractor에서, 부기 해제(setEmpty)까지 끝낸 마지막에 부르는 것이 내장 핸들러의 순서입니다. 그 ownerKey가 쥔 값이 아니면 소유권 추적이 깨진 것이므로 던집니다.

Quad0163 Bookkeeping.releaseOwner: this element is not owned by this ownerKey - ownership tracking is broken

예제 — 커스텀 값 타입 하나 붙이기

섹션 제목: “예제 — 커스텀 값 타입 하나 붙이기”

값 타입 하나와 그걸 맡을 핸들러를 등록합니다. 아래 코드는 위 프롤로그만 있으면 그대로 돕니다(등록까지가 전부이고, 실제 마운트는 그 값이 props 자리에 놓일 때 일어납니다).

-- 이 확장이 다루는 값
local function LogValue(text: string)
return table.freeze({ __logValue = text })
end
q.Dispatch.addHandler({
name = "LogValueHandler",
keyType = "string", -- 문자 키에서만 경쟁한다
isHandlable = function(_inst: any, key: any, value: any): boolean
return type(key) == "string" and type(value) == "table" and (value :: any).__logValue ~= nil
end,
priority = q.Dispatch.HANDLER_PRIORITY_NORMAL,
process = function(_inst: any, key: any, value: any, _index: number)
print(`mount {key} = {(value :: any).__logValue}`)
return function(_nextValue: any?, retracting: boolean)
print(`unmount {key} (retracting = {tostring(retracting)})`)
end
end,
})

이제 어떤 요소의 "Note" 자리에 LogValue("hello")가 놓이면 mount Note = hello가 찍힙니다. 같은 자리에 LogValue("world")가 오면 unmount Note (retracting = false) → mount Note = world 순으로, 그 자리가 q.Dispatch.retractFrom으로 철거되면 unmount Note (retracting = true)로 끝납니다(요소 Destroy는 retractor를 부르지 않고(아래), State가 q.None을 발행하면 unmount (retracting = true) 뒤에 이 예제의 isHandlable이 nil을 거부해 Quad0076이 납니다 — 165행의 nil 규칙 그대로; [2026-09-27 mock 실측]).

숫자 키 자리를 맡는 핸들러라면 process 안에서 자리 등록을 반드시 해야 합니다 — 물리 요소를 하나 놓았다면 q.Bookkeeping.claimOwnerAt(value, inst, key)로 자리를 잡은 뒤 q.Bookkeeping.setOffsetSource(inst, key, 오프셋소스) 다음 q.Bookkeeping.setLength(inst, key, 1)(retractor에서는 setEmpty 뒤 releaseOwner(value, inst)), 아무것도 놓지 않았다면 q.Bookkeeping.setEmpty(inst, key).

  • process가 던지면 그 자리의 정리는 되돌릴 수 없습니다. 엔진은 process를 pcall로 감싸지 않습니다(핫 패스이고, quad는 예외 이후의 부기 정합성을 보장하지 않습니다). 그 자리의 슬롯은 no-op 표식을 단 채 남고, 명시적 철거로도 복구되지 않습니다. retractor가 던지면 더 나쁩니다 — 슬롯이 비워지지 않아 그 키의 철거와 다른 핸들러로의 교체가 매번 같은 retractor를 다시 불러 다시 던지고, 위임한 얕은 층은 영영 철거되지 않습니다([2026-09-27 mock 실측 — 관측, 계약 아님] — 정의되지 않은 동작). retractor 안에서 같은 (inst, key)에 retractFrom을 다시 부르는 것(간접 재진입)도 정의되지 않은 동작입니다 — 체인 배열에 구멍이 생겨 Quad0071이 납니다.
  • keyType을 잘못 선언하면 조용히 빠집니다. 등록 자체는 통과하고(값이 "number"/"string"/nil 중 하나이기만 하면), 그 핸들러는 어긋난 버킷에서 아예 후보로 오르지 않습니다.
  • 동률 순서는 정의돼 있지 않습니다. 진단은 q.debug가 참일 때만 나옵니다.
  • isHandlable은 부작용 없이 순수해야 합니다(debugOnly 핸들러의 진단 경고만 예외). 매치되지 않는 스캔에서도 반복해서 불립니다.
  • Destroy로 요소가 사라질 때 retractor는 불리지 않습니다. 그건 정리가 아니라 메모리 해제이고, 체인·retractor·구독이 그 요소의 수명에 묶여 통째로 수거됩니다.
  • 이미 파괴된 인스턴스에 공개 q.Dispatch.process/drive를 직접 부르는 것은 정의되지 않은 동작입니다. 입구에 생존 검사가 없어 핸들러가 전부 조용히 “성공”합니다 — Event는 영영 Connected로 남는 연결을 만들고, OnChange는 연결하고, 프로퍼티는 값을 쓰고, 숏핸드 키(UICorner 등)는 파괴된 부모 아래 새 관리 자식을 만듭니다([2026-09-27 mock 실측 — 관측, 계약 아님]). 사용자 코드는 이 경로에 닿지 않습니다 — 인스턴스가 죽으면 그 자리의 구독이 수명과 함께 풀려 이후 Set이 아무것도 건드리지 않습니다. 노출되는 건 확장 작성자가 이 함수들을 직접 부를 때뿐이니, 부르기 전에 인스턴스가 살아 있는지는 호출하는 쪽이 확인하세요.