콘텐츠로 이동

백엔드 프로바이더 규약

대상 독자: Quad를 Roblox 외 플랫폼으로 포팅하거나 커스텀 백엔드를 작성하려는 엔지니어 정본 소스: 주입 슬롯의 타입 정의는 quad-types/src/init.luau, Roblox 구현은 quad-roblox/src/EngineOps.luau와 quad-roblox/src/LifetimeHandle.luau, 미설치 스텁 본체는 quad-base/src/NotInstalled.luau(hold·엔진·시간 op는 quad-base/src/LifetimeHandle.luau가, 태그 op는 quad-base/src/Tag.luau가, setAttr는 quad-base/src/Attr/Key.luau가 심습니다)

이 페이지의 심볼: 주입 슬롯 20개를 다섯 묶음으로 — 물리 트리 조작 native* · 판정·훅·조회 op · 생명주기 hold op · 메타데이터 op와 시간 op. 그 위의 설치 계약은 프로바이더 설치와 1슬롯 identity 락, 빠진 슬롯의 동작은 슬롯이 비어 있으면 어떻게 되나에 있습니다.


1. 아키텍처 개요: Base와 Provider의 분리

섹션 제목: “1. 아키텍처 개요: Base와 Provider의 분리”

quad-base는 상태 그래프, 의존성 전파, 슬롯 트리, 디스패치 체인, 에러 블레임을 소유합니다. 엔진에 대해서는 아무것도 모릅니다 — 요소(element)의 실제 타입이 무엇인지조차 모르고, 판정이 필요하면 주입된 술어를 부릅니다.

백엔드·플러그인이 던지는 에러도 같은 규칙입니다 — 메시지는 사람이 읽는 진단이지 API가 아닙니다. 문구는 예고 없이 다듬어질 수 있으니 코드가 문구로 분기하지 마세요(string.find(err, "...") 금지) — 실패를 코드에서 가르려면 호출 전에 조건을 확인하세요. 레퍼런스가 문구를 그대로 싣는 것은 검색해서 원인을 찾으라는 뜻입니다.

에러가 사용자 줄을 가리키게 하기 — q.errorNamespace. 백엔드·플러그인, 그리고 quad 위에 얹은 라이브러리는 자기 quad-error 사본으로 네임스페이스를 새로 만들지 말고 이 값 하나를 씁니다. 하는 일은 둘입니다.

  1. 공개 함수를 SURFACE로 태깅합니다 — q.errorNamespace.setFuncLevel(QuadTypes.ERROR_LEVEL_SURFACE, fn1, fn2, …). 내보내는 테이블을 손으로 나열하지 말고 다 지은 테이블을 한 번 순회해 함수 필드를 태깅하면, 새로 더한 함수가 빠지는 일이 없습니다. 객체를 만들어 돌려주는 생성자는 그 객체의 메소드를 만들 때 같이 태깅합니다(내보내기 순회 뒤에 생기는 함수라서).
  2. 잘못된 입력은 q.errorNamespace.errorBeforeNearest(message, QuadTypes.ERROR_LEVEL_SURFACE)로 던집니다. 스택에서 가장 안쪽의 태깅된 프레임을 찾아 그 프레임을 부른 줄을 blame하므로, 검사가 공개 함수 몇 단계 안쪽에 있어도 에러는 사용자가 그 공개 함수를 부른 줄을 가리킵니다 — error(msg, n)처럼 레벨 숫자를 손으로 셀 필요가 없습니다. 이 던지는 헬퍼 자신은 태깅하지 마세요(태깅하면 그것이 “가장 안쪽 프레임”이 되어 blame이 헬퍼를 부른 자리로 갑니다).

사본마다 태그 맵이 갈리면 경계 탐색이 끊기므로 같은 값을 쓰는 것이 핵심입니다. 같은 태그 층에 들어가면 부수 효과가 하나 있습니다 — 태깅한 공개 함수 안에서 quad 자신이 던지는 에러(예: 잘못된 Modifier가 그 함수 안쪽 요소에 닿은 경우)도 그 함수를 부른 사용자 줄을 가리킵니다. QuadNNNN 식별자는 quad의 것이니 자기 메시지에는 붙이지 말고, 자기 이름을 접두로 쓰세요(예: MyKit.Slider: Max must be greater than Min).

백엔드는 quad:UseProvider(providerFn)으로 설치되며, 프로바이더 함수는 두 가지 일을 합니다.

  1. 모듈 인스턴스를 뮤테이션해서 아래 주입 슬롯들을 채웁니다.
  2. 백엔드 고유 표면(Roblox라면 Elem/Modifier/Spread/Mapper/OnChange/OnEvent/Out/ForClasses/Animate/Tween/isTween)을 담은 확장 테이블을 반환합니다. UseProvider가 이걸 모듈에 병합합니다.

q.Backend는 엔진 경계입니다 — 답이 설치된 백엔드(엔진)의 사실에 따라 달라지는 op가 사는 자리입니다. 소속 기준은 하나입니다: quad-base가 가진 데이터만으로 답이 나오면 Backend가 아니고(브랜드 술어 q.isX, 자리 부기 q.Bookkeeping), 엔진에 물어야 답이 나오면 Backend입니다. 그 안의 op는 두 층입니다.

  • 주입 op — 백엔드만 아는 것을 백엔드가 구현해 심습니다(quad.Backend.nativeInsert = …로 심고, quad는 module.Backend.nativeInsert(…)로 부릅니다). 전부 20개이고 성격에 따라 다섯 묶음으로 나뉩니다. quad-base에는 크게 에러를 내는 안내 스텁만 있습니다(§7). 이것이 프로바이더의 구현 계약입니다.
  • 합성 op — quad-base가 주입 op만으로 합성해 심습니다 — bindLifetime·unbindLifetime·canBound·canExecute. 본문은 quad-base의 지식(Observer/Effect의 훅과 전역 구독 상태)이고, 결과는 isHeld 같은 엔진 사실에 따라 달라지므로 같은 네임스페이스에 삽니다. 프로바이더는 합성 op를 구현하지도 덮어쓰지도 않습니다 — 덮어쓰는 것은 정의되지 않은 동작입니다(막지는 않습니다).

이 목록의 단일 소스는 quad-types/src/init.luau의 Backend 레코드입니다. 이것들은 대부분 백엔드가 설치하고 quad가 부르는 계약이지 앱 코드가 부를 API가 아닙니다 — 앱의 q. 자동완성에 섞이지 않도록 따로 둔 이유입니다(앱 코드에서 물어도 되는 다섯 — 합성 op 넷과 isClaimed — 은 core/10).

층 묶음 op 심는 쪽
주입 op 물리 트리 조작 (native*) nativeInsert nativeExtract nativeRemove nativeMove nativeSwap nativeDispose 백엔드
주입 op 판정·훅·조회 op isInst onDestroying nativeClaim isClaimed nativeFindChild 백엔드
주입 op 생명주기 hold op holdLifetime releaseLifetime isHeld isHeldBy 백엔드
주입 op 메타데이터 op addTag removeTag setAttr 백엔드
주입 op 시간 op setTimeout clearTimeout 백엔드
합성 op 생명주기 프리미티브 bindLifetime unbindLifetime canBound canExecute quad-base(hold op 위에 — §4)

이 페이지의 나머지 절(§2~§5)과 “주입 슬롯 20개”는 주입 op 층을 말합니다.

두 번째 백엔드가 이미 존재합니다 — 테스트용 mock(quad-base/test/mock.luau)이 같은 계약을 전부 구현하고, 같은 UseProvider 경로로 설치됩니다. 계약이 실제로 어떻게 읽히는지 확인하고 싶다면 그 파일을 보세요 — quad 계약의 참고 구현이지 엔진 시뮬레이터가 아닙니다.

mock이 무엇을 흉내 내고 무엇을 흉내 내지 않나. 이 저장소의 mock은 quad 계약(코어·프로바이더·플러그인)을 검증하는 하네스입니다. 엔진 동작은 quad 계약이 기대는 것만 흉내 내고, 그 밖의 엔진 차이(예: 같은 값을 다시 대입해도 변경 신호를 내는 것, 레이아웃이 없는 것)는 mock을 엔진에 맞추지 않습니다 — 엔진 자체의 동작은 실기기에서 확인합니다. 그러니 mock에서 본 결과를 엔진 동작으로 읽지 마세요. 앱이나 컴포넌트 라이브러리를 엔진 없이 테스트할 소비자용 mock은 이것과 별개이고, 아직 공개되지 않았습니다(how-to 06 — 헤드리스 테스트).

_로 시작하는 필드는 이 규약에 속하지 않습니다. quad-base 모듈의 _slotInternal이나 Timeout의 _native 같은 필드는 패키지 안쪽의 내부 계약이라 언제든 바뀔 수 있습니다 — 백엔드·플러그인이 기대도 되는 표면은 이 페이지와 quad-types의 이름 있는 필드뿐입니다. (_native는 백엔드가 자기 op 사이에서만 주고받는 값이라 그 백엔드 안에서는 자유롭게 씁니다.) 한때 bindLifetime을 백엔드가 구현하면서 Observer/Effect의 밑줄 훅 셋을 예외로 불러야 했는데, 지금은 bindLifetime 자체가 quad-base 것이라 그 훅들은 백엔드가 알 필요도, 부를 일도 없습니다(아래 §4).


슬롯 트리가 요소를 삽입·추출·제거·재배치할 때 부르는 저수준 연산입니다.

nativeInsert (target: any, offset: number, elements: { any }) -> ()
nativeExtract (target: any, offset: number, elements: { any }, newElements: { any }?) -> ()
nativeRemove (target: any, offset: number, elements: { any }, newElements: { any }?) -> ()
nativeMove (target: any, fromOffset: number, elements: { any }, toOffset: number) -> ()
nativeSwap (target: any, offsetA: number, elementsA: { any }, offsetB: number, elementsB: { any }) -> ()
nativeDispose (element: any) -> ()

공통 규약 세 가지를 먼저 알아야 시그니처가 읽힙니다.

  • 대상은 언제나 요소 배열입니다. (target, offset, count)만으로 대상을 역으로 찾을 수 있는 건 DOM처럼 자식이 순서 있는 컬렉션인 플랫폼뿐입니다. Roblox에서 자식은 순서 없는 집합이고 quad의 offset은 순전히 논리값이라, 백엔드가 offset으로 인스턴스를 되찾을 수 없습니다. 그래서 빠지거나 들어가는 요소를 배열로 직접 넘깁니다. 개수는 #elements로 따라옵니다.
  • offset은 전부 0-based 절대 offset입니다. 평탄화된 리프 좌표계의 값이고, Slot 스코프 안의 인덱스가 아닙니다.
  • 배치·파동·파괴 순회 도중에 넘어가는 offset은 참고값입니다. 계약은 최종 논리 Offset뿐입니다(지금의 두 백엔드는 offset을 쓰지 않습니다. 순서를 존중하는 백엔드가 생기면 다시 봅니다).
  • Replace와 Splice는 별도 op이 아닙니다. newElements를 가진 nativeRemove(파괴하며 교체) 또는 nativeExtract(살려두고 교체)가 그 역할을 합니다. 제거와 삽입을 한 호출로 합치는 이유는 리플로우가 두 번 일어나고 그 사이 인덱스가 어긋나는 창을 없애기 위함입니다.

각 op의 뜻:

op 뜻 Roblox 구현
nativeInsert elements를 target 아래로 넣는다 element.Parent = target 반복
nativeExtract elements를 트리에서 빼되 살려둔다(+ newElements를 그 자리에 넣는다) element.Parent = nil 반복
nativeRemove elements를 트리에서 빼면서 파괴한다(+ newElements 삽입) element:Destroy() 반복
nativeMove 블록을 fromOffset에서 toOffset으로 옮긴다 의도된 no-op (아래)
nativeSwap 두 블록을 맞교환한다 의도된 no-op (아래)
nativeDispose 트리 밖에 있는 값 하나를 파괴한다 element:Destroy()

nativeMove의 toOffset은 “이동 후 블록 첫 리프의 절대 offset”이고 fromOffset은 이동 전 값입니다. nativeSwap의 두 offset은 둘 다 교환 전 값입니다 — 두 블록의 리프 수가 다르면 사이 요소가 그 차이만큼 밀립니다.

2.1 Roblox에서 nativeMove/nativeSwap이 no-op인 이유

섹션 제목: “2.1 Roblox에서 nativeMove/nativeSwap이 no-op인 이유”

Roblox 백엔드는 offset 인자를 전부 무시합니다. 자식의 배치 순서는 LayoutOrder가 정하지 Parent에 붙은 순서가 정하지 않기 때문에, 순서는 quad 쪽 장부일 뿐 물리적인 성질이 아닙니다.

그래서 nativeMove/nativeSwap은 quad-roblox에서 빈 함수입니다 — 안 심으면 다른 op와 똑같이 미설치 스텁(§7)이 던지므로, “아무 일도 안 한다”는 것도 명시적으로 심어야 합니다. .Parent를 떼었다 다시 붙여 재배치를 흉내내는 길도 있지만 그건 .Parent 쓰기 두 번(따라서 AncestryChanged 재발화와 깜빡임)을 치르고도 Roblox에서는 물리적으로 아무것도 바꾸지 않습니다. mock 백엔드도 같은 이유로 같은 선택을 합니다.

DOM처럼 자식 순서가 실제 물리 성질인 플랫폼을 쓴다면 이 둘을 진짜로 구현해야 하고, 그때 offset 인자들이 의미를 갖습니다.


native*와 달리 이 다섯은 조작이 아니라 판정·훅·조회입니다.

isInst (value: any) -> boolean
onDestroying (inst: any, fn: () -> ()) -> { Connected: boolean, Disconnect: (self: any) -> () }
nativeClaim (inst: any) -> ()
isClaimed (inst: any) -> boolean
nativeFindChild (inst: any, key: any, className: string?) -> any
  • isInst(value) — “이 값이 이 백엔드의 마운트 가능한 요소인가”. quad-base는 T가 무엇인지 모르므로 요소 타입 검증을 이 화이트리스트 술어에 전부 위임합니다. quad-roblox 구현은 typeof(value) == "Instance" 한 줄이고, mock은 “이게 mock 인스턴스인가”입니다.
  • onDestroying(inst, fn) — 요소가 파괴될 때 fn을 부르는 훅. 반환값은 Connection 모양(Connected 필드와 Disconnect 메소드를 가진 값)이어야 합니다 — Effect가 바인딩을 풀 때 이걸 끊습니다. quad-roblox 구현은 inst.Destroying:Connect(fn)입니다. 발화 시점은 동기여도 지연이어도 됩니다 — Roblox는 플레이스 설정(SignalBehavior Immediate/Deferred)에 따라 Destroying이 Destroy() 안에서 불리기도, 다음 재개점으로 미뤄지기도 하며 quad는 둘 다 견디도록 짜여 있습니다(gcconn 끊김은 언제나 동기라 isHeld가 먼저 거짓이 됩니다). mock은 동기 하나만 구현합니다. Disconnect는 콜론 호출(conn:Disconnect())이고 두 번 불러도 무해해야 합니다.
  • nativeClaim(inst) — 요소 하나를 quad 소유로 등록하는 셋업. quad-roblox에서는 여기서 GC 앵커(gchold)와 절대 발화하지 않는 시그널 연결(gcconn)을 만듭니다. New가 인스턴스를 만든 직후, 그리고 Claim이 기존 트리를 흡수할 때 요소마다 정확히 한 번 불립니다. 같은 요소를 두 번 claim하면 에러(Quad0225 nativeClaim: Instance is already claimed by quad)이고, 이미 파괴된 요소를 claim하는 것은 정의되지 않은 동작입니다(가드하지 않습니다).
  • isClaimed(inst) — “이 요소는 살아 있는 동안 quad 손에 닿았고, 지금도 살아 있는가”. 계약은 둘입니다: (1) quad의 제거 경로(q.dispose, Slot 철거 — 백엔드 쪽에서는 nativeRemove·nativeDispose)로 죽은 요소는 그 즉시 거짓이어야 합니다 — 모든 백엔드의 필수 계약입니다. (2) 엔진에서 직접 파괴된 요소(quad 밖)는, 엔진이 파괴를 동기로 알려 줄 수 있으면 역시 즉시 거짓이어야 합니다(Roblox가 그렇습니다 — 아래). 엔진이 그걸 알려 주지 못하는 백엔드는 quad 제거 경로에서 죽음을 기록(약한 맵 등)하면 (1)을 지킬 수 있고, 그 백엔드에서는 quad 밖 파괴 뒤의 결과가 정의되지 않습니다(그 백엔드의 사용자에게 q.dispose를 쓰라고 안내하세요). 파괴된 요소를 다시 claim하는 것은 정의되지 않습니다(위 nativeClaim). 앱 코드도 이 op를 물을 수 있습니다(core/10). Slot이 원소를 받을 때와 정적 자식 자리마다(q.Elem "<Class>" { child }의 InstanceChild 핸들러 — 구동 hot path) 부르고, 거짓이면 거부합니다(Quad0219 Slot: this element is not claimed by quad … / Quad0219 Dispatch: this Instance is not claimed by quad …). 싸게 만드세요 — 자식 하나당 한 번 불립니다 — 자동으로 claim해 주지 않는 것이 계약입니다. quad-roblox 구현은 nativeClaim이 남긴 셋업(gchold)이 있고 그때 만든 연결(ClassName 변경 시그널에 건, 절대 발화하지 않는 gcconn — 인스턴스가 파괴되면 엔진이 끊습니다)이 아직 살아 있는가를 봅니다 — 파괴된 인스턴스는 GC를 기다리지 않고 즉시 거짓이어야 합니다(약한 기록만 보면 다음 GC까지 시체가 claim된 것으로 읽혀 Slot이 그 시체를 앉힙니다). Claim도 해석 패스에서 트리의 모든 인스턴스에 이걸 물어 이미 소유된 것이 하나라도 있으면 아무것도 claim하지 않고 거부합니다 — 그러니 “nativeClaim이 지나간 것만 참”이어야 Claim이 성립합니다. mock은 Instance.new가 태어날 때 claim된 것(q.Elem이 만든 것의 대역)이고 밖의 것은 Instance.foreign입니다.
  • nativeFindChild(inst, key, className?) — 매퍼 디스크립터의 키로 직계 자식을 찾는 조회 op. 키가 무슨 뜻인지는 백엔드가 정합니다(Roblox는 Name, web이라면 id나 selector). 셋째 인자는 디스크립터의 클래스 이름 — 이름은 맞는데 그 클래스가 아닌 자식이면 nil을 돌려주세요(찾지 못한 것과 같이 취급되어 Claim이 “no child matched” 에러를 냅니다). quad-roblox 구현은 inst:FindFirstChild(key) 뒤 child:IsA(className)이고, mock은 ClassName 비교입니다.

3.1 자기 값 타입에 브랜드 붙이기 — Brand()

섹션 제목: “3.1 자기 값 타입에 브랜드 붙이기 — Brand()”

백엔드가 자기 값 타입(quad-roblox의 Tween처럼 디스패치가 알아봐야 하는 값)을 추가할 때 쓰는 팩토리입니다. quad-base 없이 닿아야 하므로 quad_types가 런타임 값으로 내보냅니다 — require(quad_types).Brand(). quad-base가 자기 State/Slot/Tag에, quad-roblox가 Tween에 붙이는 것과 같은 도구입니다.

호출 뜻
local B = QuadTypes.Brand() 브랜드 하나(약한 키 집합 하나를 쥔 객체)를 만듭니다
B:Register(x) x를 이 브랜드의 멤버로 등록합니다. 값을 만드는 팩토리가 부릅니다
B:Is(x) x가 등록된 멤버인가. 순수 술어 — 던지지 않고 nil에도 거짓
quad.BrandPredicates:Register(isX) 브랜드 술어 isX를 등록합니다 — 설치 함수가 받은 모듈의 필드이고(Brand() 인스턴스 하나), 디스패치 진단이 믿는 is* 함수의 집합입니다(아래)
  • 멤버 집합은 약한 키입니다 — 값이 GC되면 항목도 사라지고, 등록 해제 op는 없습니다. 역조회(어느 브랜드인가)도 없습니다.

  • 브랜드를 붙였으면 모듈 표면에 is<Brand> 술어를 얹고, 그 술어를 설치 때 받은 모듈의 quad.BrandPredicates에 등록하세요(q.isTween이 그 예) — 디스패치가 매치 실패 진단과 props 검사에서 값의 브랜드 이름을 찾을 때 모듈의 is* 필드 중 등록된 함수만 봅니다(사용자가 붙인 is* 헬퍼를 브랜드 술어로 오인하지 않기 위해서입니다). quad-base의 q.isX 술어도 같은 경로입니다 — q.NewModule()가 같은 집합에 등록하고, 디스패치는 base 브랜드를 따로 이름으로 알지 않습니다. 등록됐다는 것은 그 브랜드의 주인이 설치됐다는 뜻이라, 등록된 술어에 걸린 값은 매치 실패 메시지에 걸린 브랜드 이름이 전부 실리고 “프로바이더가 설치됐는지 확인하라”는 꼬리 대신 quad 값용 꼬리를 받습니다. 등록을 빠뜨려도 동작은 그대로이고, 그 브랜드의 값이 매치 실패 메시지에 브랜드 이름 없이(그리고 프로바이더 미설치 꼬리로) 찍히고 q.Elem "Frame" (yourValue)처럼 중괄호를 빠뜨린 호출을 못 막을 뿐입니다(술어 레퍼런스, Quad0076).

    local SpringBrand = QuadTypes.Brand()
    local function isSpring(x: any): boolean return SpringBrand:Is(x) end
    -- 설치 함수 안에서(모듈 인스턴스를 받는 자리):
    quad.isSpring = isSpring
    quad.BrandPredicates:Register(isSpring)

Luau에는 ephemeron이 없어서, “이 값이 저 요소가 사는 동안 살아 있게 하라”의 인스턴스 쪽 부기를 백엔드가 직접 구현해야 합니다. 백엔드가 심는 것은 아래 넷뿐이고, 그 위의 프리미티브 넷(bindLifetime/unbindLifetime/canBound/canExecute)은 quad-base가 조립합니다 — 값 쪽 지식(nil 게이트, 거부 메시지, Observer/Effect의 훅과 전역 구독 상태)은 전부 거기 있습니다.

holdLifetime (inst: any, value: any) -> ()
releaseLifetime (value: any) -> ()
isHeld (value: any) -> boolean
isHeldBy (value: any, inst: any) -> boolean
  • holdLifetime(inst, value) — value를 inst의 강참조 홀더에 넣고(inst가 사는 동안 value가 GC되지 않게), value 쪽에는 자기 생존 판정 근거를 약참조로 남깁니다. 이것이 커밋입니다. quad-base는 자기 게이트(nil, canBound, 값의 바인드 전 훅)를 전부 통과시킨 뒤에야 이걸 부르고, 이게 돌아오면 값의 커밋 뒤 훅(Observer의 따라잡기, Effect의 onDestroying 연결)을 돌립니다. inst가 이 백엔드의 quad 소유 요소가 아니면 아무것도 쓰지 않고 던지세요 — quad-roblox 문구는 Quad0226 bindLifetime: this Instance is not claimed by quad …(Instance가 아닌 키면 owner must be an Instance claimed by quad (got {typeof(inst)}))입니다(접두는 사용자가 부른 프리미티브 이름 bindLifetime:으로 — 사용자는 holdLifetime을 모릅니다). 순서 하나: quad-base의 값 쪽 게이트(nil, canBound)가 이 인스턴스 게이트보다 먼저 돕니다 — 이미 다른 곳에 묶인 값을 미claim 인스턴스에 넣으면 “already bound”가 납니다. mock도 같은 게이트를 가집니다(Instance.new는 태어날 때 claim된 것으로 두고 Instance.foreign만 미claim — [2026-09-27] 예전엔 여기서 lazy claim을 해 미claim 호스트가 조용히 통과했습니다). 파괴된 인스턴스를 isClaimed처럼 즉시 거부하지는 마세요 — GC 전에 다시 claim된 시체가 영원히 살아 있는 gcconn을 얻는 구멍이 있어, 여기서는 셋업 유무만 봅니다.
  • releaseLifetime(value) — 역입니다. 홀더에서 value를 빼고 남긴 근거를 지웁니다. 안 쥐고 있던 값이면 no-op, inst는 건드리지 않고, cleanup을 부르지도 안쪽 구독을 떼지도 않습니다 — 그건 quad-base의 unbindLifetime이 이걸 부르기 전에 이미 처리합니다. nil 게이트도 quad-base 몫이라 여기엔 필요 없습니다.
  • isHeld(value) — “value에 남긴 근거가 아직 살아 있는가”. quad-roblox와 mock 모두 holdLifetime이 복사해 둔 gcconn의 .Connected를 봅니다 — 인스턴스가 Destroy되면 즉시 거짓이 되고, 그 뒤 GC가 약한 항목을 치웁니다. 순수 술어입니다: 던지지 말고, nil에는 거짓을 돌려주세요. 매 emit 전파마다 불리므로 싸게 만드세요.
  • isHeldBy(value, inst) — “value를 쥔 것이 바로 이 inst인가”. quad-base가 거부 메시지 한 팔(… already bound to this Instance (the same handle at two positions?))을 고르는 데만 씁니다. 순수 술어입니다.

quad-base가 그 위에서 하는 일 — 읽는 사람이 경계를 알 수 있게 적습니다. canExecute(v)는 isHeld(v)이거나, v가 Observer/Effect이면서 전역 구독 상태(.Subscribed)가 참일 때 참이고, canBound(v)는 그 부정입니다. bindLifetime(inst, value)는 nil 게이트 → 원시값 거부(Quad0265 — 테이블·함수·userdata만) → canBound 거부(Quad0104 bindLifetime: value is already subscribed / … already bound to this Instance … / … to another Instance) → 값의 바인드 전 훅(던지면 아무것도 남기지 않음) → holdLifetime → 값의 커밋 뒤 훅 순서이고, 마지막 단계는 사용자 콜백을 돌리므로 거기서 던지면 값은 묶인 채 남습니다(정의되지 않은 동작 — pcall로 감싸지 않습니다). unbindLifetime(value)는 nil·원시값 게이트 → Effect의 Destroying 연결 해제 → **releaseLifetime**입니다. 이 넷의 사용자 표면은 core/10에 있습니다.

hold op 넷은 quad-base가 module.Backend.isHeld(v)처럼 부를 때마다 Backend 네임스페이스의 필드로 읽습니다. 백엔드 쪽에서도 자기 op를 부를 일이 있으면 같은 방식으로 읽으세요 — Init 시점에 지역 변수로 캡처해 두면 나중에 덮어쓴 실 구현이 아니라 스텁을 계속 부르게 됩니다.

Roblox에서 부모에 :Destroy()를 부르면 엔진이 자손까지 파괴하고 각각의 Destroying을 발화시킵니다. quad가 그 사실을 관측하는 통로는 onDestroying 하나이고, 서브트리의 Effect cleanup은 그 발화를 타고 돕니다.

이건 Roblox 엔진이 그렇게 동작한다는 서술이지, 백엔드에 부과된 계약이 아닙니다. 위 §1의 슬롯 목록이 주입 계약의 전부이고, 거기에 “재귀 파괴를 직접 구현하라”는 항목은 없습니다. 자기 플랫폼의 파괴 동작이 다르다면 onDestroying이 언제 불리는지가 그만큼 달라질 뿐입니다.


addTag (inst: any, names: { string }) -> ()
removeTag (inst: any, names: { string }) -> ()
setAttr (inst: any, name: string, v: any) -> ()
  • 태그 op는 이름 하나가 아니라 이름 배열을 받습니다. 배치가 계약인 이유는 웹 백엔드라면 className을 한 번에 다시 쓰는 게 자연스럽기 때문입니다. Roblox에는 배치 API가 없으므로 루프가 백엔드 쪽에 있습니다(CollectionService:AddTag / :RemoveTag).
  • setAttr(inst, name, nil)은 삭제입니다. Roblox는 inst:SetAttribute(name, nil)이 네이티브로 삭제라 그대로 위임합니다. 다른 플랫폼이라면 nil 분기를 직접 써야 합니다.
  • 값과 이름의 범위 검사는 백엔드 몫입니다. quad-base는 그룹 경로 q.Attr({ … })에서만 함수·테이블·빈 이름을 생성 시점에 거르고, 키 경로 [q.AttrKey(name)] = v와 State가 나중에 emit하는 값은 그대로 setAttr까지 옵니다 — Roblox는 SetAttribute가 지원하지 않는 타입(Array is not a supported attribute type)과 이름 규칙을 스스로 던지므로 quad-roblox는 그대로 위임합니다. mock은 테이블·함수·스레드만 거부하고 이름은 검사하지 않습니다.
setTimeout (func: () -> (), delay: number) -> Timeout
clearTimeout (timeout: Timeout) -> ()

Debounce/Throttle이 이 둘 위에 얹힙니다. Timeout은 { __quadTimeout = true, _native = ... } 모양이고 _native는 백엔드만 읽습니다 — 마커 필드 덕분에 clearTimeout이 남의 값을 받으면 바로 걸러집니다.

⚠️ task.delay와 인자 순서가 반대입니다. quad는 setTimeout(func, delay)이고 Roblox는 task.delay(delay, func)라, quad-roblox의 구현은 task.delay(delay, func)로 뒤집어 부릅니다.

이미 발화했거나 취소된 핸들에 clearTimeout을 부르는 것은 no-op입니다(Roblox task.cancel이 죽은 스레드에 조용히 no-op인 것과 같게 맞춰져 있습니다).


6. 프로바이더 설치와 1슬롯 identity 락

섹션 제목: “6. 프로바이더 설치와 1슬롯 identity 락”
-- 설치 경로는 프로젝트 구성에 따라 다르다(00-installation 참고)
local Quad = require("@game/ReplicatedStorage/roblox_packages/quad_base")
local QuadRoblox = require("@game/ReplicatedStorage/roblox_packages/quad_roblox").QuadRoblox
local q = Quad:UseProvider(QuadRoblox) -- quad-roblox 백엔드 설치: D/Tween/Animate/OnChange가 생긴다

커스텀 백엔드를 쓴다면 프로바이더 함수의 모양은 이렇습니다.

-- `Quad`는 위 프롤로그에서 require한 quad-base 모듈
local customWidgetNamespace = { -- 이 백엔드가 얹는 고유 표면
Button = function(props: any): any
return props
end,
}
local function CustomProvider(quad: any)
-- [1] 주입 슬롯을 채운다(§2~§5 전부 — 하나라도 빠지면 그 op는 안내 스텁 에러로 남는다)
quad.Backend.nativeInsert = function(target: any, _offset: number, elements: { any })
for _, element in elements do
element.Parent = target
end
end
-- ... 나머지 19개 슬롯 ...
-- [2] 백엔드 고유 표면을 확장 테이블로 만든다
local extension = {
Widget = customWidgetNamespace,
}
-- [3] 이름과 버전을 적는다 — 소비자가 `q:AssertProvider`로 확인한다
quad.Backend.provider = table.freeze({ name = "my-backend", version = "1.0.0" })
return extension
end
local q = Quad.NewModule():UseProvider(CustomProvider)

계약 넷:

  1. 모듈 인스턴스당 프로바이더는 하나입니다. 슬롯을 점유하는 키는 프로바이더 함수의 identity입니다.
  2. 같은 함수를 다시 넘기면 멱등 no-op입니다. 일반적인 경우엔 require 캐시가 같은 함수 identity를 주므로 자연히 통과합니다.
  3. 다른 identity를 넘기면 에러입니다 — 다른 백엔드든, 같은 백엔드의 다른 사본/버전이든 똑같이 막힙니다: Quad0213 UseProvider: this Quad module already has a provider - a module cannot serve two backends
  4. 슬롯 마킹은 프로바이더 함수가 성공적으로 반환한 뒤에 일어납니다. 설치 도중 던지면 슬롯이 점유되지 않으므로, 원인을 고치고 다시 부를 수 있습니다.

반환된 확장 테이블은 모듈에 키 단위로 병합됩니다(얕은 복사). quad-roblox가 반환하는 확장(RobloxExtension)은 Elem·Modifier·Spread·Mapper·OnChange·OnEvent·Out·ForClasses·Animate·Tween·isTween입니다. 병합 뒤에는 q.Elem처럼 모듈에서 바로 꺼내 쓰면 되고, 반환 타입이 교집합으로 합쳐지므로 캐스트가 필요 없습니다.

[3.4.0] 최상위 백엔드 표면의 이름 여덟은 base가 정합니다 — Elem·Modifier·Spread·Mapper·OnChange·OnEvent·Out·ForClasses. quad-base는 이 여덟 자리에 Quad0108 안내 스텁을 깔아 두고(quad-base/src/BackendStubs.luau), 프로바이더가 확장 테이블로 덮어씁니다. 다른 백엔드도 같은 이름으로 같은 일을 하는 표면을 내면 소비자 코드가 백엔드를 바꿔도 이름이 그대로입니다. 타입 없는 원재료(q.Raw.Modifier·q.Raw.Spread·q.Raw.Mapper)는 base에 있으니 타입드 표면은 그 위에 얹으면 됩니다.

백엔드가 값 층을 더한다면 자기 레퍼런스에 적습니다. quad-base의 ModifierFieldOut<T>(Modifier Peek 반환·setter 변환 함수의 old)는 base가 소유한 층(State·None·nil)만 감쌉니다. 백엔드 고유의 값 층(quad-roblox의 Tween<T>)은 base가 알 수 없으므로, 백엔드의 타입드 표면에서 채우고 무타입 q.Raw.Modifier()에서는 호출자가 T 안에 넣는다는 것을 그 백엔드 문서에 밝혀 두세요(core/08의 층 나눔).

QuadRoblox는 설치 시점에 받은 quad-base 인스턴스의 Version을 자기 패턴과 대조하고, 안 맞으면 그 자리에서 던집니다. 패턴(type_version_check의 문법)은 이렇게 읽힙니다.

  • |로 먼저 나눈 대안 중 하나라도 맞으면 통과합니다. 메이저 여럿과 호환되는 백엔드는 확인한 구간을 나열합니다 — "3.3^.0^|4.0^.0^".
  • 대안마다 + 뒤 빌드 메타데이터를 버리고, 첫 -에서 core와 프리릴리즈 꼬리로 나눕니다. 꼬리의 있고 없음은 양쪽이 같아야 합니다 — 꼬리 없는 패턴("3.*.*" 포함)은 rc 빌드를 받지 않고, 꼬리 있는 패턴은 릴리즈를 받지 않습니다. rc도 받으려면 꼬리 달린 대안을 더합니다.
  • core와 꼬리를 각각 .로 나눠 따로 판정합니다(자리 개수가 다르면 불일치). 자리마다 *(뭐든) / N^(숫자로 N 이상 — N보다 크면 그 구간의 뒤 자리는 보지 않는 사전식 하한) / 그 외 정확 일치입니다. 꼬리의 rc·beta 같은 글자 자리는 정확 일치뿐입니다("3.3.0-rc.1^"은 3.3.0의 rc.1 이후 rc).
  • N^의 N은 ASCII 10진 정수입니다. 그 밖의 표기(16진·지수·inf·nan·부호·공백)는 정의되지 않습니다. ^ 앞이 숫자가 아니면(x^, ^ 단독) 그 자리는 일치하지 않습니다.

락은 프로바이더가 아니라 UseProvider 안에 있으므로, 프로바이더 작성자가 가드를 빠뜨려도 바깥에서 두 번 부르는 것은 구조적으로 막힙니다. 막지 않는 것 둘(둘 다 정의되지 않은 동작 — 아래 결과는 관측, 계약 아님; [2026-09-27 mock 관측]): (1) 프로바이더 본문 안에서 q:UseProvider(다른 것)을 부르면 슬롯이 아직 비어 있어 둘이 다 병합되고 슬롯엔 바깥 것만 남습니다 — “roblox + 사내 확장”을 한 프로바이더로 감싸려면 UseProvider를 안에서 부르지 말고 확장을 AddPlugin으로 얹으세요(자기 자신을 다시 부르면 상한 없이 재귀합니다); (2) 설치 함수는 yield하지 마세요 — 표시는 성공 뒤에 남으므로 yield 중 다른 스레드가 같은 함수로 UseProvider/AddPlugin을 부르면 본문이 두 번 돌고 핸들러가 두 번 등록됩니다(quad-roblox 자체엔 yield가 없습니다). AddPlugin은 같은 병합을 하되 락이 없습니다 — 백엔드 팩토리를 AddPlugin으로 넘기는 것은 지원 대상이 아닙니다.

provider: { read name: string, read version: string }?

동작 — 설치된 프로바이더가 자기 이름과 버전을 적어 두는 자리입니다. 프로바이더가 주입하는 목록의 하나지만 함수가 아니라 데이터이고, quad-base는 여기에 스텁을 두지 않습니다(적지 않으면 nil). q:AssertProvider(name, versionPattern)가 이 기록을 읽습니다 — 킷 같은 소비자가 “이 q에 내가 쓰는 백엔드가 깔려 있나”를 프로바이더 함수의 identity 대신 이름과 버전으로 확인합니다. 그래서 다른 패키지 사본끼리도 같은 프로바이더로 알아봅니다.

  • 다른 op처럼 프로바이더 본문에서 적습니다. 설치가 성공한 경우에만 남도록 확장을 다 만든 뒤에 적으세요. quad-roblox는 { name = "quad-roblox", version = <자기 버전> }을 얼려서 적습니다.
  • version은 SemVer 문자열이고, AssertProvider는 위 패턴 문법으로 대조합니다.
local function CustomProvider(quad: any)
-- ... 슬롯을 채우고 확장 표를 만든다 ...
local extension = { Widget = customWidgetNamespace }
quad.Backend.provider = table.freeze({ name = "my-backend", version = "1.2.0" })
return extension
end

7. 슬롯이 비어 있으면 어떻게 되나

섹션 제목: “7. 슬롯이 비어 있으면 어떻게 되나”

quad-base는 위 20슬롯 전부에 안내 스텁을 깔아둡니다. 백엔드 없이 부르면 nil 호출 크래시가 아니라 이름이 박힌 에러가 호출자 줄에서 납니다.

Quad0108 quad: nativeInsert is not available - no backend has installed the lifetime primitives / engine ops (install a provider with quad:UseProvider - a bare Quad.NewModule() has none; headless tests install their own provider the same way)

이 스텁은 프로바이더가 덮어쓸 때까지만 공개 표면에 앉아 있습니다. 즉 백엔드가 슬롯 하나를 빠뜨리면 그 op를 처음 쓰는 순간 그 이름이 그대로 에러 메시지에 나옵니다 — 조합으로 대신 만들어 주는 폴백은 없습니다.