콘텐츠로 이동

왜 Quad인가 — 다른 도구와 비교하기

대상 독자: 이미 Fusion·Vide·react-lua 중 하나로 Roblox UI를 만들고 있고, “Quad로 넘어올 값이 있나”를 판단하려는 엔지니어. 하는 일: 도구마다 그 도구가 잘하는 것 → 거기서 겪는 문제 → Quad는 어떻게 풀었거나 피했나 → 넘어올 이유와 넘어오지 않을 이유를 나란히 적습니다. 마지막에 없는 것과 그 이유를 따로 모았습니다 — 일부러 안 한 것, 만들 예정인 것, 오늘의 성숙도일 뿐인 것, 구조상 앞으로도 안 메워지는 것이 섞여 있어 구분해 두는 편이 판단에 도움이 되기 때문입니다. 안 하는 일: 사용법은 여기 없습니다. 손으로 먼저 만들어 보실 분은 시작하기가 앞에 있고, 이 문서는 그 전에 읽어도 그 뒤에 읽어도 됩니다. 내부 원리는 The Quadnomicon이 따로 있습니다.


Quad는 Roblox 클라이언트 UI를 위한 DOMless UI 렌더러입니다. 가상 DOM을 두지 않습니다 — D.Frame { ... }을 호출하는 그 자리에서 실제 Instance가 만들어집니다. 이후의 변화는 트리를 다시 그리는 것이 아니라 바뀐 프로퍼티 하나에만 도달합니다. 목표는 프로덕트를 빨리 내는 것이 아니라 라이브러리로서의 정합성과 지속 가능성입니다.

Fusion도, Vide도, quad 자신의 v1도 같은 문제들을 각자의 방식으로 풀었고 그 풀이는 실제로 동작합니다. 반응 그래프를 어떻게 전파시킬지, 정리를 누가 책임질지, 확장점을 어디에 열지 — 어느 것도 정답이 하나인 문제가 아닙니다. 우리가 초점을 둔 것은 셋이었습니다 — (1) 사용자가 정리를 위해 들고 다녀야 하는 물건을 없앨 것, (2) 상태 그래프가 같은 계산을 두 번 하지 않을 것, (3) 라이브러리 내부를 고치지 않고도 확장할 수 있을 것. 각 선택에는 전부 대가가 있고, 아래에 그 대가도 같이 적습니다.

코드가 어떻게 생겼는지만 먼저 보시려면 아래 예제 하나로 충분합니다. 인프라 매개변수가 없다는 것, 그리고 props의 문자 키(프로퍼티·이벤트)와 숫자 키(스타일·자식)가 다른 뜻을 갖는다는 것만 보면 됩니다.

local isHovered = q.Source(false)
local CommonButtonModifier = D.Modifier.Frame { BorderSizePixel = 0 }
D.Frame {
Size = UDim2.new(0, 200, 0, 50), -- 문자 키: 프로퍼티
BackgroundColor3 = isHovered:Compute(function(h) -- 문자 키: 반응형 바인딩
return if h:Get() then Color3.fromRGB(80, 120, 240) else Color3.fromRGB(50, 50, 60)
end),
MouseEnter = function() isHovered:Set(true) end, -- 문자 키: 이벤트(self는 안 온다)
CommonButtonModifier, -- 숫자 키: 재사용 스타일
D.TextLabel { Text = "클릭하세요" }, -- 숫자 키: 자식
}

2. 이런 분께 맞습니다 / 맞지 않습니다

섹션 제목: “2. 이런 분께 맞습니다 / 맞지 않습니다”
  • 정리 코드를 손으로 관리하는 데 지친 경우. 컴포넌트는 인프라 매개변수를 받지 않는 평범한 (props) -> Instance 함수이고, 정리할 것을 담아 들고 다니는 스코프 객체가 없습니다.
  • 의존성이 코드에 드러나 있는 쪽을 선호하는 경우. 파생값의 입력은 :Compute(fn, ...deps) / :With(...)적어야만 잡힙니다 — 우연한 캡처가 없어 재실행 조건을 정적으로 읽습니다.
  • 컴포넌트가 형제를 여럿 돌려줘야 하는 UI. 래퍼 Frame 대신 Slot으로 다루고, 재마운트는 조용히 두 벌이 생기는 대신 즉시 에러가 납니다.
  • 라이브러리를 고치지 않고 확장하고 싶은 경우. D.Frame { … } 안에 올 수 있는 키와 값의 종류를 코어가 정하지 않습니다. 핸들러 하나를 등록하면 새 값 타입과 새 props 어휘가 그 자리에서 인식되고, 우선순위 축도 열려 있어 서드파티가 임의 순서로 끼어듭니다. 렌더 백엔드도 같은 식으로 부착합니다(UseProvider 한 줄) — Roblox 백엔드 자신이 그 공개 경로로 설치된 첫 손님이고, 헤드리스 mock도 같은 문으로 들어옵니다.
  • 에러를 grep해서 소스로 되돌아가고 싶은 경우. 메시지는 주어: 이유 모양이고, 받은 값을 말할 때만 (got X) 꼬리가 붙습니다. 리터럴은 던지는 그 줄에 통째로 남습니다.

도입 자체를 막는 오늘 시점의 차단기만 적습니다.

  • 설치 경로가 pesde 하나입니다. [2026-09-10 기준] pesde 레지스트리에 게시돼 있고, Wally 게시와 .rbxm 릴리스는 아직 없습니다.
  • 타입 검사에 luau 플래그 넷이 필수입니다. LuauSolverV2 없이는 소스의 타입 검사가 실패하고, LuauTarjanChildLimit을 안 올리면 D.Frame { Name = "x" } 한 줄이 “Code is too complex to typecheck”로 죽습니다 — 편집기에도 같은 플래그가 필요합니다.
  • Quad가 만든 Instance는 참조를 놓는 것만으로 회수되지 않습니다. Destroy가 유일한 절단면이라, 화면을 버릴 때 Destroy()를 안 부르는 코드 스타일이면 맞지 않습니다.

“이건 없나?” 쪽 판단 재료는 이 문서 맨 아래 8. 없는 것과 그 이유에 모아 뒀습니다 — 같은 “없음”이라도 일부러 안 한 것, 만들 예정인 것, 오늘의 현황일 뿐인 것이 섞여 있어 목록만 봐서는 갈리지 않기 때문입니다.


Vide가 잘하는 것. 배우고 쓰는 부담이 가볍습니다. derive() 안에서 다른 값을 그냥 읽으면 의존성이 잡히므로 목록을 적을 일이 없고, 확장점 action(callback, priority)은 등록 절차 없이 값을 던지기만 하면 인식됩니다. 정리 모델도 배열 하나가 아니라 의존성 엣지와 구조적 소유(owner/owned)를 별개 관계로 분리해 뒀고, 실제로 릴리스를 거치며 하드닝된 이력이 있습니다 — destroy가 반응 의존까지 타고 내려가던 것을 고쳤고, 활성 스코프를 destroy하는 것을 막는 하드 가드가 나중에 추가됐습니다. spring()이 그래프의 1급 노드라 애니메이션 값을 다른 파생값의 입력으로 자유롭게 얽을 수 있는 것도 Quad엔 없는 장점입니다.

거기서 겪는 문제. 전파가 순수 push입니다 — 소스를 쓰는 즉시, 동기적으로, 깊이우선으로 의존 노드를 재평가하며 lazy 경로가 없습니다. 그래서 다이아몬드 모양 그래프에서 같은 노드가 중복 재평가되는 문제가 남고, 저자들 스스로 이것을 todo.md에 미해결로 적어 뒀습니다. 암묵 추적에도 대가가 있어서, 리액티브 스코프 안에서 yield가 일어나 그래프가 깨지는 것을 막는 별도 장치가 따로 필요합니다. mount()엔 단일 마운트 가드가 전혀 없어 같은 타깃에 두 번 마운트하면 독립된 루트가 둘 생기고 자식이 조용히 중복됩니다. 정리는 완전히 eager·수동이라 빠뜨리면 샙니다. 확장점 action()은 콜백과 우선순위만 받고 key/value 쌍은 버립니다.

Quad는 어떻게 풀었나. Set은 “바뀌었다”는 신호만 보내고 계산은 :Get() 시점에 노드 캐시를 통해 일어납니다 — 신호가 몇 갈래로 도착하든 계산은 한 번이라 다이아몬드 중복 재평가가 구조적으로 생기지 않습니다(설계 선택 (2)). 의존성은 손으로 적으므로 암묵 추적도, 그것을 지키는 yield 방어 장치도 필요 없습니다. 이미 마운트된 Slot의 재마운트는 즉시 던집니다(설계 선택 (3)). 스코프 객체는 아예 없고 생존은 엔진 커넥션으로 판정합니다(설계 선택 (4)). 바인드 핸들러는 key·value·타깃을 모두 받고 우선순위 축이 열린 숫자 공간입니다(설계 선택 (6)).

넘어올 이유. 다이아몬드 그래프에서 계산 횟수가 예측 가능해야 하거나, 정리를 위해 스코프를 들고 다니는 것을 그만두고 싶은 경우입니다. 같은 것을 두 번 마운트하는 실수가 조용히 지나가지 않기를 바라거나, 라이브러리를 포크하지 않고 key/value를 받는 커스텀 바인드를 임의 우선순위로 끼워 넣어야 할 때도 여기 해당합니다(설계 선택 (8)). 엔진 없이 렌더 결과를 검증하거나 다른 렌더 타깃을 염두에 둔다면 부착식 백엔드(설계 선택 (7))가 그 창구입니다.

넘어오지 않을 이유. derive()의 가벼움을 포기해야 합니다 — 의존성을 전부 :With / :Compute(fn, ...deps)에 나열해야 하고, 그 목록은 실행 중에 바뀌지 않습니다. 무엇이 언제 회수되는지의 모양도 다릅니다 — Vide는 effect를 destroy해 effect가 붙들고 있던 인스턴스 참조를 놓게 하므로 Destroy 없이도 인스턴스가 GC될 수 있지만, Quad는 반대로 Destroy 전까지 붙잡습니다(설계 선택 (4)). spring()처럼 애니메이션 값을 다른 파생값의 입력으로 쓰던 코드는 그대로 옮겨오지 않습니다.

암묵 추적을 포기하면 코드가 얼마나 늘어나나요?

파이프 하나당 의존성 이름을 뒤에 나열하는 만큼입니다. 콜백 안에서 읽는 것만으로는 잡히지 않고, 읽을 대상을 인자로 적어야 합니다.

local suffix = q.Source("회")
local both = count:Compute(function(c, previous, s)
return `{c:Get()}{s:Get()}`
end, suffix) -- ← suffix를 적어야 suffix가 움직일 때도 다시 흐른다

늘어난 것은 , suffix 한 조각이고, 얻는 것은 “이 파이프가 언제 다시 도는가”를 호출부에서 정적으로 읽을 수 있다는 점입니다. 자세한 것은 03. 값이 흐르게 하기에 있습니다.


Fusion이 잘하는 것. 반응 그래프가 push 무효화 + pull 재계산 하이브리드라 Computeduse()될 때까지 재계산하지 않습니다 — 읽지 않는 파생값은 계산되지 않는다는 성질을 Quad보다 먼저 취한 설계입니다. 의존성이 명시적 use()라는 점도 Quad와 같은 축이고, 여기엔 Quad에 없는 부수 효과가 하나 붙습니다 — use한 값의 Scope가 이미 destroy됐는지를 그 자리에서 검사할 수 있습니다. 확장점 SpecialKey는 모양 자체가 열려 있어 누구나 만들 수 있고, doCleanup은 Instance·Connection·함수·테이블을 다형적으로 받아 역순으로 티어다운합니다. 무엇보다 수년간 실전에서 쓰이며 발견되고 고쳐진 문제들의 산물입니다.

거기서 겪는 문제. 게으름이 전부에 적용되지는 않습니다 — timeliness="eager"로 표시된 노드(Observer/Tween/Spring)는 즉시 동기 재계산해야 하고, 그 때문에 글리치를 막으려 eager 집합을 생성 순서로 정렬하는 장치가 붙습니다. 애니메이션을 그래프의 1급 노드로 둔 대가는 더 큽니다: 매 프레임 틱하는 외부 시간 소스, 즉시 재계산되는 eager 노드, 애니메이션과 입력 사이의 교차 lifetime 검증이라는 삼중 장치가 딸려옵니다. 정리 모델인 Scope는 사실 배열 + 메타테이블에 생성자들이 자기 destroy 클로저를 쌓는 구조라, 완전히 eager·수동이며 사용자가 그 물건을 들고 다녀야 합니다. SpecialKey는 모양은 열려 있어도 우선순위가 self/descendants/ancestor/observer 4단계로 하드코딩돼 있어 다섯 번째를 쓰면 에러가 납니다. 자식 처리에는 마운트 소유권 가드가 없습니다 — Children.luau-- TODO: check for ancestry conflicts here 주석이 그대로 남아 있고 이미 마운트된 인스턴스를 조건 없이 재부모화합니다.

Quad는 어떻게 풀었나. 재계산을 하는 자리는 :Get() 하나뿐이라 eager 노드도, 생성 순서 정렬도 없습니다(설계 선택 (2)). Tween은 그래프 노드가 아니라 프로퍼티 자리에 꽂는 값이라 프레임 클럭·eager·교차 lifetime 체크 삼중 장치가 아예 없습니다(설계 선택 (5)). 스코프 객체를 없애고 생존 판정을 엔진 커넥션에 위임했습니다(설계 선택 (4)). 우선순위 축은 닫힌 4단계가 아니라 열린 숫자 공간입니다(설계 선택 (6)). 재마운트는 조용한 중복 대신 즉시 에러입니다(설계 선택 (3)).

넘어올 이유. 컴포넌트에서 인프라 매개변수를 걷어내고 싶거나, 라이브러리를 고치지 않고 다섯 번째 우선순위나 새 값 타입을 끼워 넣어야 하는 경우입니다(설계 선택 (8)). 애니메이션이 반응 그래프를 오염시키지 않기를 바라거나, 같은 것을 두 번 마운트했을 때 에러가 나기를 바랄 때도 마찬가지입니다.

넘어오지 않을 이유. use() 명시성이 가능하게 하던 수명 검사는 Quad에 없습니다 — 다만 그 검사가 잡는 것은 이미 destroy된 자기 Scope의 값을 use했는가이고, Quad엔 Scope 자체가 없어 그 버그 클래스가 생기지 않습니다. 없는 안전망이라기보다 필요 없어진 검사에 가깝습니다(사용자가 직접 들고 계신 Instance 참조까지 추적해 주는 층이 없다는 것은 별개이고, 설계 선택 (4)에 적었습니다). Tween/Spring을 Computed의 입력으로 합성하던 코드는 그대로 옮겨오지 않고, 명령형 트윈 API도 없습니다. 4단계 우선순위로 충분하고 Scope를 들고 다니는 것이 불편하지 않다면, 바꿔서 얻는 것이 거의 없습니다.


react-lua가 잘하는 것. 렌더마다 서브트리를 통째로 다시 기술하기 때문에 “지금 트리가 어떻게 생겼는가”가 코드 한 곳에 드러납니다. 조건부 분기가 복잡할수록 이 성질이 주는 이점이 커지고, 이 축에서는 Quad가 집니다. Roblox 사내 실사용과 전용 벤치마크 레포까지 있는 물건입니다.

거기서 겪는 문제. 리스트를 diffing하려면 key 관리가 필요하고, key가 불안정하면 자식 상태가 유실됩니다. 훅에는 호출 순서 규칙이 있어 어기면 렌더된 훅 수가 맞지 않는다는 에러가 납니다. 그리고 고빈도 갱신에는 리렌더를 우회하는 별도 API를 공식적으로 덧붙여야 했습니다 — vdom 재조정만으로는 부족하다는 것을 그 라이브러리 스스로 인정한 셈입니다.

Quad는 어떻게 풀었나. 중간 트리도 diffing 단계도 없어 위 세 문제가 발생할 자리 자체가 없습니다(설계 선택 (1)). 컴포넌트는 한 번 실행되는 평범한 함수라 훅 호출 순서 규칙이 없고, 고빈도 갱신이라고 해서 다른 경로를 쓰지 않습니다 — 모든 값이 같은 모델을 통과합니다.

넘어올 이유. key 관리와 훅 순서 규칙을 지우고 싶은 경우, 갱신 빈도에 따라 API를 갈아타지 않기를 바라는 경우입니다.

넘어오지 않을 이유. 트리 가시성을 포기해야 합니다 — 변화가 개별 leaf 바인드로 흩어지므로 복잡한 조건부 트리를 코드만 읽고 재구성하기 어렵습니다. 이건 설계로 해소되는 종류가 아니라 관측 도구로만 보완되고, 그 도구는 아직 없습니다(아래 8. 없는 것과 그 이유의 “아직 없지만 계획이 있다” 참고). 트리를 거슬러 올라가 값을 찾아 주는 Context도 없습니다 — 층을 건너 값을 넘기는 자리는 아무것도 조회하지 않는 명시적 가방(q.Context)입니다 → 14. 층을 건너 값 넘기기.


세 라이브러리를 나란히 서술할 근거가 있는 축만 남겼고, 성능 수치는 어느 쪽에 대해서도 싣지 않습니다.

Fusion Vide Quad
전파 모델 push 무효화 + pull 재계산 하이브리드. eager로 표시된 노드만 즉시 재계산하고, 글리치를 막으려 생성 순서로 정렬 순수 push — 즉시·동기·깊이우선으로 재평가. 다이아몬드 중복 재평가는 저자들이 미해결로 남김 push는 신호만, 재계산은 :Get() 시점 pull. 두 경로로 신호가 와도 계산은 한 번
의존성 선언 명시적 use() — 이미 destroy된 Scope의 값을 use했는지 검사할 수 있음 암묵적(전역 스코프 스택) — 리액티브 스코프 안 yield를 막는 별도 장치가 필요 명시적 :With / :Compute(fn, ...deps)
정리·스코프 모델 Scope 배열에 destroy 클로저를 모아 수동 티어다운. GC 비의존 의존성 엣지와 소유(owner/owned)를 분리한 수동 destroy. GC 비의존 스코프 객체 없음. 생존 판정은 엔진 커넥션(.Connected — 전환이 Destroy와 동기)이고 회수는 GC — 대신 Destroy가 유일한 절단면
마운트 소유권 이미 마운트된 인스턴스를 조건 없이 재부모화(가드 없음) 중복 마운트 체크 없음 — 같은 타깃에 두 번 마운트하면 루트가 둘 재마운트는 즉시 에러
Frame { … } 안의 어휘를 누가 정하나 라이브러리 — SpecialKey 모양은 열려 있으나 우선순위가 4단계 하드코딩 라이브러리 — action(callback, priority)은 등록은 필요 없지만 key/value를 받지 않음 사용자 — (요소, 키, 값)을 판별하는 핸들러 레코드 하나를 등록하면 새 값 타입·새 키가 코어 수정 없이 인식, 우선순위는 열린 숫자 공간, 백엔드의 Property/Event/Tween도 같은 경로로 등록된 핸들러
렌더 백엔드 Roblox 전용 — 엔진 API를 직접 부름 create가 Roblox Instance를 직접 만듦 — 교체 창구 없음 부착식 — 코어는 엔진 op를 주입받고, 백엔드는 UseProvider(provider) 한 줄로 설치(Roblox 백엔드·헤드리스 mock이 같은 문)
애니메이션 그래프의 1급 노드 — 다른 파생값의 입력으로 합성 가능, 대신 프레임 클럭·eager·교차 lifetime 검증 spring()이 그래프 노드 — 합성 가능 그래프 밖의 값 — 보간·프레임 타이밍은 엔진(TweenService)이 담당해 딸려오는 장치가 없고, 대신 합성이 안 됨

7. 설계 선택 — 이전 선택, 우리 선택, 그리고 대가

섹션 제목: “7. 설계 선택 — 이전 선택, 우리 선택, 그리고 대가”

위 절들이 가리키는 항목들입니다. 각 항목은 같은 뼈대입니다 — 이전 프레임워크가 무엇을 골랐고 어디서 막혔는지, 우리는 어떻게 넘었는지, 그리고 그 선택에 딸려오는 것.

마지막 칸은 성질이 셋으로 갈립니다. 한 라벨로 뭉쳐 두면 전부 “쓰면 겪을 위험”으로 읽히기 때문에 갈라 적습니다.

  • 치른 대가 — 우리 구조가 원인이고 채택하시면 실제로 겪는 비용입니다. 그걸로 무엇을 샀는지 같이 적습니다.
  • 알아 둘 것 — 계약·규칙이거나 엔진이 정한 성질입니다. 손해가 아니라 안내입니다.
  • 다른 관점 — 반대편도 그만한 대가를 내는 축입니다. 어느 쪽이 낫다고 말할 수 없습니다.
  • 이전 선택과 그 한계: Fusion도 Vide도 quad v1도 가상 DOM을 두지 않았고, 이 선택은 그대로 이어받았습니다. 반대편 길인 vdom 재조정은 리스트 key 관리를 요구하고(불안정하면 자식 상태가 유실됩니다), 훅 호출 순서 규칙을 강제하며, 고빈도 갱신엔 리렌더를 우회하는 별도 API를 공식적으로 덧붙여야 했습니다(react-lua).
  • 우리가 넘은 방법: D.Frame { ... }이 그 자리에서 실제 Instance를 만들어 돌려주고, 변화는 개별 프로퍼티 바인드에만 도달합니다. 중간 트리도 diffing 단계도 없어서 위 세 문제 자체가 생기지 않습니다.
  • 치른 대가: “지금 트리가 어떻게 생겼는가”가 코드 한 곳에 드러나지 않습니다. 변화가 leaf 바인드로 흩어져 복잡한 조건부 트리는 재구성하기 어렵습니다. 렌더마다 서브트리를 통째로 다시 기술하는 vdom 쪽이 이 축에서는 낫습니다. 산 것은 위의 셋입니다 — key 관리도, 훅 호출 순서 규칙도, 고빈도 갱신용 별도 API도 생길 자리가 없습니다. 설계 변경으로 해소되는 종류가 아니라 관측 도구로만 보완되고, 그 자리를 메울 도구는 §8의 “아직 없지만 계획이 있다”quad-debug로 적어 뒀습니다.
  • 자세히: 02. 첫 화면 — 부른 순간 실물 Instance, Quadnomicon Vol. 9 — DOMless Slot 트리

(2) 밀 때는 신호만, 계산은 읽을 때 — 그리고 의존성은 손으로 적는다

섹션 제목: “(2) 밀 때는 신호만, 계산은 읽을 때 — 그리고 의존성은 손으로 적는다”
  • 이전 선택과 그 한계:
    • Vide — 순수 push라 소스를 쓰는 즉시 동기·깊이우선으로 의존 노드를 재평가하고, 저자들이 다이아몬드 그래프의 중복 재평가 방지를 todo.md에 미해결로 남겼습니다. 의존성은 전역 스코프 스택으로 암묵 추적하는데, 그 때문에 리액티브 스코프 안의 yield가 그래프를 깨는 걸 막는 별도 장치까지 필요했습니다.
    • Fusion — push 무효화 + pull 재계산 하이브리드지만 eager로 표시된 노드는 즉시 재계산해야 해서, 글리치를 막으려 eager 집합을 생성 순서로 정렬합니다.
  • 우리가 넘은 방법: Set은 “바뀌었다”는 신호만 보내고 값 계산을 하지 않습니다. 재계산은 :Get() 시점에 노드 캐시를 통해 일어나므로 신호가 두 경로로 와도 계산은 한 번이고, 다이아몬드 중복 재평가는 이 모델에서 구조적으로 발생하지 않습니다 — Vide가 미해결로 남긴 자리입니다. 의존성은 :With / :Compute(fn, ...deps)로 적으므로 암묵 추적이 필요한 yield 방어 장치도 없습니다.
  • 다른 관점: 의존성을 전부 손으로 나열해야 하고, derive() 안에서 그냥 읽으면 잡히던 Vide 쪽 편의는 여기 없습니다. 다만 덜 적는 쪽을 고르면 암묵 추적이 따라오고, 그 추적을 지키는 방어 장치(리액티브 스코프 안 yield 차단)까지 딸려옵니다 — 어느 쪽이 낫다고 말할 수 있는 축이 아니라, 무엇을 코드에 드러낼지의 선택입니다. 반복되는 조합을 줄이는 방향은 암묵 추적이 아니라 이름 붙은 조합입니다: q.Operator 콤비네이터는 명시성을 유지한 채 같은 파이프를 짧게 씁니다(줄어드는 것은 글자 수이고, 의존성 선언 자체는 그대로입니다) → 레퍼런스 Operator.
  • 알아 둘 것: 의존성 목록은 정적이라 실행 중에 바뀌지 않습니다.
  • 자세히: 17. 값은 언제 흐르나, Quadnomicon Vol. 1 — 32-bit Wrapping Revision과 EpochMap

(3) 형제 여럿을 다루는 자리를 Slot으로 만들고, 마운트에 소유권을 건다

섹션 제목: “(3) 형제 여럿을 다루는 자리를 Slot으로 만들고, 마운트에 소유권을 건다”
  • 이전 선택과 그 한계: 두 라이브러리 다 마운트에 소유권 가드가 없습니다. Fusion Children.luau-- TODO: check for ancestry conflicts here가 그대로 남아 있고 이미 마운트된 인스턴스를 조건 없이 재부모화합니다. Vide mount.luau도 중복 마운트 체크가 전혀 없어 같은 타깃에 두 번 마운트하면 독립된 루트가 둘 생깁니다. 둘 다 조용히 두 벌이 됩니다.
  • 우리가 넘은 방법: Slot이 자식 위치를 장부로 들고, 이미 마운트된 Slot의 재마운트는 즉시 던집니다. 실재하는 버그 클래스를 막는 가드이고 두 라이브러리 어디에도 없습니다.
  • 알아 둘 것: LayoutOrder는 직접 넣으셔야 합니다 — updateFn이 받는 index/offset에서 대입하면 됩니다. 대신 채워 주지 않는 것 자체는 Fusion·Vide도 같습니다(Vide의 defaults.luau가 정해 주는 것은 SortOrder 기본값까지입니다). quad가 다른 점은 그 자리의 index/offset반응형 값으로 쥐어 준다는 것이고, 이미 지정해 둔 값을 조용히 덮지 않으려고 대입만 남겼습니다. 소유권 가드가 막는 것도 실재하는 버그 클래스(조용히 두 벌이 되는 것)뿐이라, 한 자리에 한 번 마운트하는 정상 사용에서는 걸리지 않습니다.
  • 자세히: 10. 자식이 들어갈 자리, Quadnomicon Vol. 9 — 컴포넌트가 형제 여럿을 반환하는 문제와 DOMless Slot 트리, Vol. 2 — Slot-in-Slot 부분합 트리
  • 이전 선택과 그 한계:
    • FusionScope는 배열 + 메타테이블에 생성자마다 destroy 클로저를 쌓고 doCleanup이 역순으로 티어다운합니다.
    • Vide — 의존성 엣지와 구조적 소유(owner/owned)를 분리했고, destroy가 reactive dependent까지 타고 내려가던 것을 나중에 고쳤으며 활성 스코프 destroy 하드 가드도 뒤이어 넣었습니다.
    • 둘 다 완전히 eager·수동이라 한 군데만 빠뜨려도 정리되지 않은 채 남습니다.
    • quad v1 — 더 나빴습니다. 통일된 정리 모델 자체가 없어 PropertyChangedSignal로 참조를 붙잡는 GC 방지 핫팩이 여러 곳에 중복됐고 대칭되는 해제 경로가 없었습니다.
  • 우리가 넘은 방법: 생존은 엔진 커넥션이 끊겼는지로 판정하고 회수는 Luau GC에 맡깁니다. 수동으로 disconnect할 것도, 정리할 것을 담아 들고 다니는 스코프 객체도 없습니다.
  • 치른 대가 (a): Quad가 만든 Instance는 참조를 놓는 것만으로 회수되지 않고 Destroy로만 회수됩니다. 산 것은 userdata 신원 고정입니다 — Roblox의 Instance 값은 엔진 객체를 가리키는 Lua userdata 포인터라, 아무도 참조하지 않으면 트리에 멀쩡히 살아 있는 인스턴스의 userdata가 한 GC 사이클 안에 회수되고 나중에 같은 객체를 다시 얻으면 다른 userdata가 옵니다. quad의 부기는 대부분 inst를 키로 하는 릴레이션 위에 있으므로, 고정하지 않으면 그 항목 전체가 조용히 미아가 되고 조회는 실패가 아니라 nil로 돌아옵니다. 덤으로, 참조를 아무도 들고 있지 않아도 그 인스턴스에 매달린 값이 먼저 사라지지 않습니다. 그리고 바인딩이 하나라도 걸린 인스턴스는 어느 라이브러리에서든 옵저버 클로저가 inst를 잡아 같은 순환이 생깁니다 — 새로 생긴 제약이라기보다, 아직 아무것도 안 걸린 인스턴스까지 같은 규칙으로 통일한 것입니다.
  • 치른 대가 (b): 스코프 없는 모델이 자기 때문에 내는 비용이 둘 있습니다. 컴포넌트가 트리를 만드는 도중에 던지면, 그때까지 만들어진 부분 트리는 자기 앵커로 스스로를 붙들고 있어서 격리 경계인 Fallback도 회수해 주지 않습니다(레퍼런스 Fallback). 그리고 quad 밖에서 부모를 Destroy한 뒤에는 그 아래 있던 값을 다른 자리에 다시 마운트할 수 없습니다 — 살려서 옮기실 값은 파괴 전에 :Extract로 빼 두셔야 합니다(레퍼런스 Slot). 산 것은 사용자가 들고 다녀야 하는 인프라 물건이 하나도 없다는 것입니다.
  • 알아 둘 것: 생존 판정은 Destroying이 아니라 인스턴스에 걸어 둔 엔진 커넥션의 .Connected이고, 그 전환은 Destroy와 동기입니다 — 파괴 뒤의 발화는 순서와 무관하게 막힙니다(Quadnomicon Vol. 6). Destroying 시그널에 기대는 소비자는 Effect의 cleanup 하나인데, 시그널 동작이 Deferred로 설정된 플레이스에서는 그 cleanup이 파괴와 같은 줄기가 아니라 직후에 지연 배달됩니다. cleanup이 파괴와 같은 프레임에 동기로 돈다는 전제를 두지 마세요 — 이건 quad의 선택이 아니라 엔진 성질입니다.
  • 알아 둘 것: quad가 소유한 경로(프로퍼티·이벤트·Slot)는 파괴 뒤 발화가 게이트로 막히고, 파괴된 Instance를 다시 바인드하면 그 자리에서 에러가 납니다. 없는 것은 사용자가 직접 들고 계신 Instance 참조까지 추적해 주는 층입니다. Roblox가 “이 인스턴스는 깨끗이 파괴됐는가”를 묻는 술어를 주지 않아서, 그걸 제대로 하려면 모든 Instance 사용을 감싸야 합니다 — 그건 렌더러가 아니라 전용 Instance 래퍼 도구의 영역이라고 보고 흡수하지 않았습니다(만들 계획도 없습니다).
  • 자세히: Quadnomicon Vol. 3 — Luau 메모리 토폴로지, Vol. 7 — 인스턴스 신원·네이티브 GC·Claim 계약

(5) 애니메이션을 반응 그래프 밖에 둔다

섹션 제목: “(5) 애니메이션을 반응 그래프 밖에 둔다”
  • 이전 선택과 그 한계: Fusion의 Tween/Spring은 반응 그래프의 1급 노드입니다. 그래서 매 프레임 틱하는 Stopwatch/ExternalTime 소스, 즉시 재계산되는 eager 노드, 애니메이션과 입력 사이의 교차 lifetime 검증이라는 삼중 장치가 딸려옵니다. quad v1의 :Tween은 register 메타테이블 체인의 한 고리라 같은 메소드를 두 번 부르면 마지막 것만 남았습니다.
  • 우리가 넘은 방법: Tween은 노드가 아니라 프로퍼티 자리에 꽂는 값입니다. 그래프는 비즈니스 상태가 바뀔 때만 돌고 보간은 엔진 레이어가 하므로, 프레임 클럭도 eager 노드도 교차 lifetime 체크도 Quad엔 아예 없습니다.
  • 다른 관점: 산 것은 보간과 프레임 타이밍을 엔진(TweenService)이 맡는다는 것입니다 — 렌더 라이브러리가 트윈을 직접 구현하면 엔진의 애니메이션 관리를 전혀 따르지 못하고, 그 대신 Lua 그래프 안으로 프레임 클럭과 eager 노드가 들어옵니다. 세 도구 중 보간을 엔진에 넘기는 것은 quad뿐입니다. 남는 사실 하나는 그대로 남습니다 — 트윈 중인 값을 되읽어 다른 파생값의 입력으로 쓸 수 없습니다. 라이브 읽기도 완료 훅도 없어서, 애니메이션 값을 그래프에 얽던 코드는 그대로 옮겨오지 않습니다. 그래프 노드로 두는 Fusion/Vide 쪽이 이 축에서는 낫고, 이 자리를 메우는 방향은 §8의 “아직 없지만 계획이 있다”에 “지속 업데이트 원천”으로 적어 뒀습니다.
  • 알아 둘 것: 명령형 API도 커스텀 함수 이징도 스텝 콜백도 없습니다. 엔진이 이징을 Enum.EasingStyle로만 받기 때문에 생기는 위임의 귀결입니다.
  • 자세히: 15. 움직이게 하기, 08. quad v1에서 v2로 옮기기 — 제거된 기능과 이관 경로, 05. 테마와 동적 스타일링

(6) 스타일시트 대신 Modifier, 그리고 열린 우선순위 축

섹션 제목: “(6) 스타일시트 대신 Modifier, 그리고 열린 우선순위 축”
  • 이전 선택과 그 한계:
    • quad v1style.lua는 이름 매칭 기반이고 선언 순서에 의존해서, 실행 순서가 꼬이면 스타일이 안 먹는 문서화된 함정이 있었습니다. 특수 키를 하나 더 넣으려면 class.lua의 하드코딩된 중앙 디스패처를 직접 고쳐야 했습니다.
    • FusionSpecialKey는 모양은 열려 있지만 우선순위가 self/descendants/ancestor/observer 4단계로 하드코딩돼 다섯 번째를 쓰면 에러가 납니다.
    • Videaction(callback, priority)은 등록이 필요 없는 대신 key/value 쌍을 받지 않습니다.
    • Roblox 네이티브 StyleSheet — 이름으로 멀리서 거는 창구는 엔진이 이미 줍니다. StyleRule의 선택자가 곧 CollectionService 태그이기 때문입니다. 다만 적용 위치가 태그 선택자를 통해야 해서, 컴포넌트 코드 안에서 스타일을 값으로 합성하거나 diff하기는 어렵다고 보고 Modifier를 별도 축으로 뒀습니다 — 두 축은 배타적이지 않습니다.
  • 우리가 넘은 방법: 재사용 스타일은 디스패치 이전에 정적으로 평탄화되는 불변 값(Modifier)이라 런타임 캐스케이드 계산이 없고, 핸들러는 key·value·타깃을 모두 받으며 우선순위 축은 열린 공간입니다. 라이브러리를 고치지 않고 다섯 번째 우선순위를 끼워 넣을 수 있습니다.
  • 알아 둘 것: 이름으로 멀리서 일괄 적용하고 싶으시면 그 창구는 엔진 쪽입니다 — quad의 q.Tag가 선언한 태그(State에서 나온 태그도 됩니다)를 StyleRule의 선택자가 그대로 고르므로, 태그 위에 스타일시트를 얹어 바깥에서 모양을 갈아 끼우는 구성은 그대로 됩니다 → 05. 이름표와 속성. Modifier가 더하는 것은 그 위의 두 번째 축입니다 — 합성되고, 코드로 diff되고, 텍스트로 이식되는 스타일. 없어진 것은 id 하나를 겨냥하던 v1의 Style "Child" {}뿐이고, 그 이관은 v1에서 오는 분께로 넘깁니다. Modifier 쪽 우선순위 규칙은 “숫자 키 자리에서 뒤에 온 것이 이김”과 “인라인 키가 무조건 이김” 둘뿐이라, 넘기는 쪽이 직접 배치하시면 됩니다.
  • 자세히: 09. 스타일을 값으로, Quadnomicon Vol. 8 — 확장 가능한 디스패치 엔진과 우선순위 파이프라인

(7) 렌더 백엔드를 부착식으로 만든다 — 코어에서 엔진 어휘를 뺀다

섹션 제목: “(7) 렌더 백엔드를 부착식으로 만든다 — 코어에서 엔진 어휘를 뺀다”
  • 이전 선택과 그 한계: Fusion도 react-lua도 Roblox 전용이고, Vide의 create도 Roblox Instance를 직접 만듭니다 — 렌더링이 엔진 API에 직접 묶여 있어 다른 타깃으로 갈 창구도, 엔진 없이 돌릴 창구도 없습니다. 이건 관측된 벽이라기보다 설계 시점의 판단이었습니다 — 렌더 기술이 한 엔진에 묶이면 외부 개발자 유인이 없어 발전이 더디다고 봤습니다.
  • 우리가 넘은 방법: quad-base는 엔진을 모릅니다. 물리 트리 조작(nativeInsert·nativeRemove…)·생명주기·메타데이터·시간 같은 op를 주입받는 순수 코어이고, 백엔드는 그 op와 자기 핸들러(Property/Event/Tween)를 들고 Quad:UseProvider(provider) 한 줄로 부착됩니다. Roblox 백엔드 quad-roblox는 특권이 없는 첫 손님입니다 — 코어에 Roblox 분기가 하나도 없고, 같은 공개 경로로 설치됩니다. 헤드리스 검증에 쓰는 mock 백엔드도 같은 문으로 들어오므로, 엔진 없이 렌더 결과를 검증하는 것과 다른 렌더 타깃을 붙이는 것이 같은 일입니다. 프로바이더 슬롯은 모듈 인스턴스당 하나이고, 같은 프로바이더의 재설치는 no-op, 다른 프로바이더는 즉시 에러라 두 백엔드가 조용히 섞이지 않습니다.
  • 알아 둘 것: 백엔드는 얇은 다리가 아니라 Property·Event·Tween 핸들러를 실제로 소유하므로, 새 백엔드를 만드는 비용은 op 몇 개를 채우는 것보다 큽니다. 다만 그 비용은 백엔드를 쓰는 쪽이 아니라 만드는 쪽만 냅니다 — 그리고 이음매가 아예 없어 포크가 유일한 길인 쪽보다는 쌉니다. 실제로 그 계약을 전부 통과하는 두 번째 구현이 이미 있습니다(백엔드 계약에 참고 구현으로 적어 뒀습니다). 오늘 출하되는 프로덕션 백엔드가 몇 개인지는 설계의 대가가 아니라 현황이라 §8의 “오늘의 성숙도”에 적었습니다.
  • 자세히: 레퍼런스: 백엔드 프로바이더 규약, 06. 헤드리스 테스트, Quadnomicon Vol. 10 — 다중 백엔드 추상 기계

(8) Frame { … } 안에 무엇이 올 수 있는지를 코어가 정하지 않는다 — 핸들러와 플러그인

섹션 제목: “(8) Frame { … } 안에 무엇이 올 수 있는지를 코어가 정하지 않는다 — 핸들러와 플러그인”
  • 이전 선택과 그 한계: 어느 라이브러리든 { … } 안에서 인식되는 키와 값의 종류는 라이브러리가 정합니다. Fusion의 SpecialKey는 새 키를 만들 수는 있지만 우선순위 4단계 안에서만이고, Vide의 action은 콜백 하나를 던지는 창구라 새 값 타입을 가르칠 수 없으며, quad v1은 class.lua의 중앙 디스패처가 하드코딩이라 특수 키 하나를 더 넣으려면 라이브러리를 고쳐야 했습니다.
  • 우리가 넘은 방법: props의 자리 하나하나는 디스패치 엔진으로 가고, 엔진은 등록된 핸들러들을 우선순위대로 훑어 그 (요소, 키, 값)을 맡을 첫 핸들러를 찾습니다. 핸들러는 레코드 하나입니다 — 순수 판별 isHandlable, 숫자 priority, 처리하고 **되돌리는 함수(retractor)**를 돌려주는 process. 사용자가 이 레코드를 q.Dispatch.addHandler로 등록하면 그 순간부터 새 값 타입(스프링 값, 로그 값, 무엇이든)이나 새 키가 D.Frame { … } 안에서 인식됩니다. 값을 한 겹 벗겨 아래로 위임하는 래핑 핸들러는 같은 자리에 층으로 쌓이고 무를 때 역순으로 풀립니다. 백엔드의 Property·Event·Tween도 특권이 아니라 같은 레지스트리에 등록된 핸들러이고, 그 위에 얹는 Modifier·Slot·Ref·Observer도 마찬가지입니다. 새 표면(q.MySpring 같은 팩토리)까지 붙이려면 q:AddPlugin(fn)이 돌려준 테이블을 모듈에 병합해 줍니다 — 타입도 Self & P 교집합으로 이어져 캐스트 없이 자동완성됩니다. 그래서 코어·백엔드·플러그인이 전부 분리 가능한 층입니다.
  • 알아 둘 것: 핸들러 작성에는 지켜야 할 계약이 있습니다 — isHandlable은 매치되지 않는 스캔에서도 반복해서 불리므로 순수해야 하고, process는 retractor를 반드시 돌려줘야 합니다. 확장점을 여는 라이브러리라면 어디에나 있는 종류의 계약입니다. 레지스트리는 모듈 인스턴스 스코프라 q.New()로 따로 만든 인스턴스에는 다시 등록해야 합니다 — require가 주는 기본 인스턴스만 쓰시면 해당 없고, q.New()는 드문 경로입니다.
  • 치른 대가: 우선순위 축을 열어 둔 값으로, 닫힌 축에는 없는 실패 모드가 생깁니다(핸들러 계약). keyTypeisHandlable과 어긋나게 선언하면 등록은 통과하는데 에러 없이 그 버킷에서 빠지고, 더 낮은 우선순위의 핸들러가 대신 매치됩니다. 우선순위 동률의 순서는 정의돼 있지 않고 경고는 q.debug가 참일 때만 나옵니다. process가 던진 자리는 no-op 표식을 단 채 남고 명시적 철거로도 복구되지 않습니다(핫 패스라 pcall로 감싸지 않습니다). 숫자 키 자리를 맡는 말단 핸들러는 자리마다 길이와 오프셋 소스를 둘 다 등록해야 하고, 하나만 빠뜨리면 그 자리가 아니라 한참 뒤의 오프셋 조회에서 터집니다. 산 것은 라이브러리를 포크하지 않고 새 값 타입·새 키·다섯 번째 우선순위를 끼워 넣을 수 있다는 것입니다.
  • 자세히: 레퍼런스: 디스패치 핸들러 계약 — 예제 “커스텀 값 타입 하나 붙이기”, 레퍼런스: Quad 모듈 — AddPlugin, Quadnomicon Vol. 8 — 확장 가능한 디스패치 엔진과 우선순위 파이프라인

(덤) 에러를 grep 가능하게 만든다

섹션 제목: “(덤) 에러를 grep 가능하게 만든다”
  • 이전 선택과 그 한계: 여기서 갈린 상대는 외부 라이브러리가 아니라 우리 자신입니다. 프로젝트 안에서 Err.format(subject, reason, got) 류 포맷 헬퍼가 제안됐고, 헬퍼를 두면 메시지가 조각나 로그에 찍힌 문장을 들고 소스로 grep해 돌아가는 경로가 끊긴다는 이유로 기각됐습니다.
  • 우리가 넘은 방법: 메시지 리터럴은 던지는 줄에 통째로 남기고(보간은 값 부분에만), 공개 표면 함수에 태그를 달아 스택에서 걷어낸 뒤 사용자 줄을 blame합니다.
  • 알아 둘 것: blame이 짚는 줄에는 경계가 있고, 그 경계는 quad의 선택이 아니라 Luau error(level)의 성질입니다. C 프레임이 태그된 표면을 직접 부르면(pcall(fn, …)처럼 함수를 그대로 넘기는 경우) 파일:줄 접두가 사라집니다 — 메시지 자체는 그대로 남으므로 grep 경로는 끊기지 않습니다. 재진입에서는 최외곽 스캔이 바깥 진입 줄을 짚습니다(안쪽 줄이 필요한 자리는 errorBeforeNearest 쌍을 자리마다 가려 씁니다) → Quadnomicon Vol. 11.
  • 알아 둘 것: OnRendered가 보장하는 것은 자기 아래(서브트리)의 완성이지 자기 위가 아닙니다 — 부모에 붙었음은 보장하지 않습니다(생명주기 훅).
  • 자세히: 09. 디버깅과 트러블슈팅, Quadnomicon Vol. 11 — 정적 grep 가능성, 표면 blame, 에러 아키텍처

다른 도구에서 넘어오면 제일 먼저 손이 가는 자리들입니다. 같은 “없음”이라도 판단 재료가 다르므로 아래 소절로 갈라 적습니다 — 일부러 안 한 것인지, 만들 예정인 것인지, 오늘의 성숙도일 뿐인지, 구조상 앞으로도 안 메워지는 것인지.

  • 트리를 거슬러 올라가 조회하는 암묵 맥락이 없습니다. React식 Context는 렌더 트리와 훅 호출 순서 위에 서는 장치인데, Quad엔 그 둘이 없습니다. Luau에도 함수와 함수 사이로 맥락을 흘려보내는 언어 장치가 없어서, 흉내 내려면 전역 스택을 두고 “지금 어느 컴포넌트를 그리는 중인가”를 관리해야 합니다 — Vide가 의존성 추적에 쓰다가 yield 방어 장치를 따로 두게 된 바로 그 물건입니다. 그래서 q.Context는 아무것도 조회하지 않는 명시적 가방입니다. 줄어드는 것은 중간 층이 알아야 할 이름의 수이지, 넘기는 행위 자체가 아닙니다 → 14. 층을 건너 값 넘기기.
  • 암묵 의존성 추적이 없습니다. 전역 스코프 스택으로 “함수 실행 중과 끝 사이”를 관찰하는 방식은 Lua에서 부작용 없이 깔끔하게 되지 않는다고 보고 기각했습니다. 대신 :Compute(fn, ...deps) / :With(...)에 적습니다 — 늘어난 만큼, 재실행 조건을 호출부에서 정적으로 읽습니다. 반복되는 조합을 줄이는 방향은 암묵 추적이 아니라 이름 붙은 조합입니다 → 레퍼런스 Operator.
  • 의존성 목록이 실행 중에 바뀌지 않습니다. 동적 의존성은 “State는 불변”이라는 전역 가정과 정면으로 부딪히고(목록 변경이 후행 노드 전체로 파급돼야 합니다), 실사용 사례도 사실상 없다고 봤습니다 — React의 useMemo deps 배열도 실무에서는 거의 항상 정적으로 나열됩니다.
  • 정리할 것을 담는 스코프 객체가 없습니다. 생존 판정을 엔진 커넥션에, 회수를 GC에 위임했기 때문입니다. 사용자가 들고 다니는 인프라 물건은 하나도 없고, 절단면은 Destroy 하나입니다(그 대가는 설계 선택 (4)).
  • 명령형 트윈 API가 없습니다. RunTween/StopTween/IsTweening도, 커스텀 함수 이징도, 스텝 콜백도 없습니다. 애니메이션을 그래프 밖의 으로 둔 선택(설계 선택 (5))의 귀결이라, 남는 것은 선언(q.Tween{} / q.Animate{})과 겹침 정책(Override)뿐입니다.
  • quad 자신의 스타일시트 층이 없습니다. 이름으로 멀리서 거는 창구 자체는 엔진이 줍니다 — StyleRule의 선택자가 곧 CollectionService 태그이고, q.Tag가 선언한 태그를 그대로 고릅니다. 그 위에 quad가 또 하나의 캐스케이드를 얹는 대신, 스타일을 디스패치 전에 평탄화되는 불변 값(Modifier)으로 두어 합성·diff·이식이 되는 두 번째 축을 골랐습니다(설계 선택 (6)). 우선순위 규칙은 둘뿐이고, id 하나를 겨냥하던 v1의 Style "Child" {}는 폐지했습니다.
  • id로 아무 데서나 요소를 찾아오는 창구가 없습니다. 전역 조회는 폐지됐고 Ref는 그 대체재가 아닙니다 — Ref의 관례는 만든 쪽이 직접 쓰거나 자식에게 넘겨 쓰는 것까지이고, 경계 밖으로 반출하거나 전역에 오래 보관하는 용도가 아닙니다.

날짜도 버전도 약속하지 않습니다 — 방향만 밝힙니다.

  • 트리와 파이프의 가시성 → quad-debug(예정). 위 (1)의 대가로 남은 “지금 트리가 어떻게 생겼는가”를 메우는 자리입니다. Studio 플러그인으로, 실물 Instance에서 그것을 만든 코드 위치를 역추적하고, 플러그인 자체 트리 뷰에서 Quad가 관리하는 계층을 보여주고, 어떤 프로퍼티가 어떤 State/Source 파이프에 물려 있는지를 그래프로 보여주는 것을 목표로 합니다. 화면을 클릭해 그 자리의 요소를 고르는 피킹, 마운트·언마운트 flash도 범위에 있습니다(개별 프로퍼티 변경 flash는 노이즈가 커서 열어 본 인스턴스 한정으로 좁혔습니다). 값 조회는 노드를 누를 때만 계산합니다 — 디버그 도구가 자동으로 펼쳐 값을 읽으면 원래 필요 없었을 계산을 도구가 유발하기 때문입니다. 이 설계를 막던 유일한 기술적 불확실성(플러그인이 실행 중인 클라이언트와 실시간으로 통신할 수 있는가)은 해소됐고, 남은 것은 세부 API와 구현입니다.
  • 애니메이션 값의 합성 → 지속 업데이트 원천(예정). 위 (5)의 대가인 “애니메이션 값을 다른 파생값의 입력으로 못 쓴다”를 메우는 방향입니다. Tween을 그래프 안으로 되돌리는 것이 아니라, 스프링 물리처럼 스스로 계속 갱신되는 원천을 별도 확장 패키지로 두어 기존 파이프와 자연스럽게 엮이게 하는 갈래를 보고 있습니다. 이름도 표면도 아직 정하지 않았습니다.
  • 헤드리스 검증용 공개 mock 백엔드(예정). 지금도 테스트 안에서는 mock 백엔드로 렌더 결과를 검증하지만, 그건 테스트 내부 물건이라 사용자가 쓸 수 없습니다. 이것을 공개 패키지로 꺼내는 것이 계획입니다 — 두 번째 프로덕션 렌더 백엔드는 아닙니다.

설계가 막는 것이 아니라 아직 거기까지 안 간 것들입니다.

  • 출하되는 프로덕션 렌더 백엔드는 [2026-09-10 기준] quad-roblox 하나입니다. 창구는 열려 있고 계약도 이미 두 번째 구현이 통과하고 있지만(테스트용 mock 백엔드가 같은 계약을 전부 구현하고 같은 UseProvider 경로로 설치됩니다 → 백엔드 계약), 그건 아직 사용자가 쓸 수 있는 물건이 아닙니다.
  • 사용자가 직접 들고 계신 Instance 참조를 추적해 주는 층은 앞으로도 없습니다. quad가 소유한 경로는 파괴 뒤 발화가 게이트로 막히지만, 그 밖의 참조까지 보려면 모든 Instance 사용을 감싸야 합니다 — Roblox가 “깨끗이 파괴됐는가”를 묻는 술어를 주지 않기 때문입니다. 전용 Instance 래퍼 도구의 영역이라고 보고 흡수하지 않았습니다(옵트인 계획도 없습니다).
  • 스코프 없는 모델이 못 메우는 자리 둘: 생성 도중 던진 컴포넌트의 부분 트리는 Fallback도 회수하지 않고, quad 밖에서 부모를 Destroy한 뒤에는 그 아래 있던 값을 다시 마운트할 수 없습니다. 둘 다 설계 선택 (4)에 적었습니다.