Skip to content

왜 Quad인가 — 이전 선택을 안 따른 이유, 그리고 그 대가

This content is not available in your language yet.

대상 독자: Roblox 클라이언트 UI를 만드는 엔지니어. “이걸 배울 값이 있나”를 30분 안에 판단하려는 사람. 하는 일: 설계 선택마다 이전 프레임워크가 무엇을 골랐고 어디서 막혔는지, 우리는 어떻게 넘었는지, 그 대신 무엇이 나빠졌는지를 같이 적습니다. 안 하는 일: 사용법도 내부 알고리즘도 아닙니다(쓰는 법은 Getting Started, 원리는 The Quadnomicon — 각 항목에서 링크합니다).


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

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


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

섹션 제목: “2. 이런 분께 맞습니다 / 맞지 않습니다”
  • 정리 코드를 손으로 관리하는 데 지친 경우. 컴포넌트는 인프라 매개변수를 받지 않는 평범한 (props) -> Instance 함수이고, 정리할 것을 담아 들고 다니는 스코프 객체가 없습니다.
  • 의존성이 코드에 드러나 있는 쪽을 선호하는 경우. 파생값의 입력은 :Compute(fn, ...deps) / :With(...)적어야만 잡힙니다 — 우연한 캡처가 없어 재실행 조건을 정적으로 읽습니다.
  • 컴포넌트가 형제를 여럿 돌려줘야 하는 UI. 래퍼 Frame 대신 Slot으로 다루고, 재마운트는 조용히 두 벌이 생기는 대신 즉시 에러가 납니다.
  • 라이브러리를 고치지 않고 확장하고 싶은 경우. 바인드 키의 우선순위 축이 열려 있어 서드파티 핸들러가 임의 우선순위로 끼어들 수 있습니다.
  • 에러를 grep해서 소스로 되돌아가고 싶은 경우. 메시지는 주어: 이유 (got X) 한 모양이고 리터럴은 던지는 그 줄에 통째로 남습니다.

오늘 시점의 차단기들입니다.

  • 설치 경로가 pesde 하나입니다. [2026-09-10 기준] pesde 레지스트리에 3.0.0이 게시돼 있고, Wally 게시와 .rbxm 릴리스는 아직 없습니다.
  • 실전 사용 이력이 없습니다. Fusion·Vide·react-lua는 실전에서 발견되고 고쳐진 문제들의 산물이고 Quad엔 그 이력이 없습니다 — 설계 검토로 대체되지 않는 격차입니다.
  • 타입 검사에 luau 플래그 넷이 필수입니다. LuauSolverV2 없이는 소스가 파싱조차 안 되고, LuauTarjanChildLimit을 안 올리면 D.Frame { Name = "x" } 한 줄이 “Code is too complex to typecheck”로 죽습니다 — 편집기에도 같은 플래그가 필요합니다.
  • 출하되는 프로바이더 구현은 quad-roblox 하나입니다. 코어(quad-base)는 op를 주입받는 구조지만 실제 렌더 백엔드는 그것뿐입니다(테스트용 mock은 테스트 안에만 있습니다).
  • 명령형 트윈 API가 없습니다. RunTween/StopTween/IsTweening도, 커스텀 함수 이징도, 스텝 콜백도 없습니다 — 선언(q.Tween{} / q.Animate{})과 겹침 정책(Override)뿐입니다.
  • 트리를 거슬러 올라가는 Context가 없습니다. q.Context는 아무것도 조회하지 않는 명시적 가방이라 중간 계층이 손으로 넘겨야 합니다.
  • Quad가 만든 Instance는 참조를 놓는 것만으로 회수되지 않습니다Destroy가 유일한 절단면이라, 화면을 버릴 때 Destroy()를 안 부르는 코드 스타일이면 맞지 않습니다.
  • use-after-destroy 안전망이 없습니다. 파괴된 인스턴스를 계속 쓰는 코드를 런타임에 검증하는 층은 의도적으로 두지 않기로 확정됐습니다(옵트인 계획도 없음).
  • id로 아무 데서나 요소를 찾아오는 창구가 없습니다 — 전역 조회는 폐지됐고 Ref는 그 대체재가 아닙니다.
  • 디버깅 도구가 아직 없습니다. 공개 quad-mockquad-debug도 백로그이고, 헤드리스 검증은 테스트 내부의 mock 백엔드로만 합니다.

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

섹션 제목: “3. 설계 선택 — 이전 선택, 우리 선택, 그리고 대가”
  • 이전 선택과 그 한계: Fusion도 Vide도 quad v1도 가상 DOM을 두지 않았고, 이 선택은 그대로 이어받았습니다. 반대편 길인 vdom 재조정은 리스트 key 관리를 요구하고(불안정하면 자식 상태가 유실됩니다), 훅 호출 순서 규칙을 강제하며, 고빈도 갱신엔 리렌더를 우회하는 별도 API를 공식적으로 덧붙여야 했습니다(react-lua).
  • 우리가 넘은 방법: D.Frame { ... }이 그 자리에서 실제 Instance를 만들어 돌려주고, 변화는 개별 프로퍼티 바인드에만 도달합니다. 중간 트리도 diffing 단계도 없어서 위 세 문제 자체가 생기지 않습니다.
  • 그 대가 / 더 나쁜 점: “지금 트리가 어떻게 생겼는가”가 코드 한 곳에 드러나지 않습니다. 변화가 leaf 바인드로 흩어져 복잡한 조건부 트리는 재구성하기 어렵습니다. 렌더마다 서브트리를 통째로 다시 기술하는 vdom 쪽이 이 축에서는 낫습니다. 설계 변경으로 해소되는 종류가 아니라 관측 도구로만 보완됩니다.
  • 자세히: 멘탈 모델 1 — 가상 DOM은 없다, 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 쪽 인체공학은 여기 없습니다. 의존성 목록은 정적이라 실행 중에 바뀌지 않습니다.
  • 자세히: 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에서 직접 바인딩해야 합니다. 이미 지정한 값을 조용히 덮는 매직이 되고 코어가 Roblox 어휘에 묶이기 때문에 하지 않는 쪽을 골랐습니다.
  • 자세히: Quadnomicon Vol. 9 — 컴포넌트가 형제 여럿을 반환하는 문제와 DOMless Slot 트리, Vol. 2 — Slot-in-Slot 부분합 트리
  • 이전 선택과 그 한계: Fusion의 Scope는 배열 + 메타테이블에 생성자마다 destroy 클로저를 쌓고 doCleanup이 역순으로 티어다운합니다. Vide는 의존성 엣지와 구조적 소유(owner/owned)를 분리했고, 0.2.0에서 destroy가 reactive dependent까지 타고 내려가던 것을 고쳤으며 0.4.0에 활성 스코프 destroy 하드 가드를 넣었습니다. 둘 다 완전히 eager·수동이라 빠뜨리면 샙니다. quad v1은 더 나빴습니다 — 통일된 정리 모델 자체가 없어 PropertyChangedSignal로 참조를 붙잡는 GC 방지 핫팩이 여러 곳에 중복됐고 대칭되는 해제 경로가 없었습니다.
  • 우리가 넘은 방법: 생존은 엔진 커넥션이 끊겼는지로 판정하고 회수는 Luau GC에 맡깁니다. 수동으로 disconnect할 것도, 정리할 것을 담아 들고 다니는 스코프 객체도 없습니다 — 사용자가 들고 다니는 인프라 물건이 하나도 없다는 점은 두 라이브러리 대비 우위입니다.
  • 그 대가 / 더 나쁜 점: 셋 다 Quad 쪽이 나쁩니다. (a) Quad가 만든 Instance는 참조를 놓는 것만으로 회수되지 않고 Destroy로만 회수됩니다. (b) GC와 Destroying의 순서가 비결정적이라는 알려진 함정을 그대로 안고 갑니다 — Vide는 정확히 그 이유로 반대편(eager 수동)을 골랐습니다. (c) use-after-destroy 사전 검증 안전망이 없고, 만들 계획도 없습니다.
  • 자세히: 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엔 아예 없습니다.
  • 그 대가 / 더 나쁜 점: 애니메이션 값을 다른 Compute의 입력으로 합성할 수 없습니다. 그래프 노드로 두는 Fusion/Vide 쪽이 이 축에서는 낫고, 그렇게 쓰던 코드는 그대로 옮겨오지 않습니다. 명령형 API도 커스텀 함수 이징도 스텝 콜백도 없습니다.
  • 자세히: 08. quad v1에서 v2로 옮기기 — 제거된 기능과 이관 경로, 05. 테마와 동적 스타일링

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

섹션 제목: “(6) 스타일시트 대신 Modifier, 그리고 열린 우선순위 축”
  • 이전 선택과 그 한계: quad v1의 style.lua는 이름 매칭 기반이고 선언 순서에 의존해서, 실행 순서가 꼬이면 스타일이 안 먹는 문서화된 함정이 있었습니다. 특수 키를 하나 더 넣으려면 class.lua의 하드코딩된 중앙 디스패처를 직접 고쳐야 했습니다. Fusion의 SpecialKey는 모양은 열려 있지만 우선순위가 self/descendants/ancestor/observer 4단계로 하드코딩돼 다섯 번째를 쓰면 에러가 납니다. Vide의 action(callback, priority)은 등록이 필요 없는 대신 key/value 쌍을 받지 않습니다. CSS식 스타일시트는 적용 위치가 트리 상위여야 해서 스크립팅으로 조립하기 어렵다고 보고 처음부터 후보에서 뺐습니다.
  • 우리가 넘은 방법: 재사용 스타일은 디스패치 이전에 정적으로 평탄화되는 불변 값(Modifier)이라 런타임 캐스케이드 계산이 없고, 핸들러는 key·value·타깃을 모두 받으며 우선순위 축은 열린 공간입니다. 라이브러리를 고치지 않고 다섯 번째 우선순위를 끼워 넣을 수 있다는 점은 Fusion 대비 우위입니다.
  • 그 대가 / 더 나쁜 점: 이름으로 멀리서 일괄 적용하는 창구가 없습니다. id를 겨냥하던 v1의 Style "Child" {}는 폐지됐고, 우선순위 규칙은 “배열에서 뒤에 온 것이 이김”과 “인라인 키가 무조건 이김” 둘뿐이라 넘기는 쪽이 직접 배치해야 합니다.
  • 자세히: Quadnomicon Vol. 8 — 확장 가능한 디스패치 엔진과 우선순위 파이프라인
  • 이전 선택과 그 한계: Fusion도 react-lua도 Roblox 전용입니다. 이건 관측된 벽이 아니라 설계 시점의 판단이었습니다 — 렌더 기술이 한 엔진에 묶이면 외부 개발자 유인이 없어 발전이 더디다고 봤고, 코어가 엔진 어휘를 직접 부르면 헤드리스로 돌릴 수도 다른 렌더 타깃으로 갈 수도 없습니다.
  • 우리가 넘은 방법: quad-base는 백엔드 op를 주입받는 순수 코어이고, Roblox 구현은 quad-roblox가 담당합니다. 헤드리스 검증도 같은 주입 경로로 들어갑니다.
  • 그 대가 / 더 나쁜 점: 출하되는 백엔드가 하나뿐이라 분리의 값은 아직 검증되지 않았습니다(다른 타깃은 염두에 뒀을 뿐 구현이 없습니다). 백엔드는 얇은 다리가 아니라 Property·Event·Tween 핸들러를 실제로 소유하므로 새 백엔드의 비용은 op 몇 개보다 크고, 프로바이더는 모듈 인스턴스당 한 슬롯입니다.
  • 자세히: Quadnomicon Vol. 10 — 다중 백엔드 추상 기계

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

섹션 제목: “(덤) 에러를 grep 가능하게 만든다”
  • 이전 선택과 그 한계: 여기서 갈린 상대는 외부 라이브러리가 아니라 우리 자신입니다. 프로젝트 안에서 Err.format(subject, reason, got) 류 포맷 헬퍼가 제안됐고, 헬퍼를 두면 메시지가 조각나 로그에 찍힌 문장을 들고 소스로 grep해 돌아가는 경로가 끊긴다는 이유로 기각됐습니다.
  • 우리가 넘은 방법: 메시지 리터럴은 던지는 줄에 통째로 남기고(보간은 값 부분에만), 공개 표면 함수에 태그를 달아 스택에서 걷어낸 뒤 사용자 줄을 blame합니다.
  • 그 대가 / 더 나쁜 점: C 프레임이 태그된 표면을 직접 부르면 파일:줄 접두가 사라지고, 재진입에서는 바깥 진입 줄이 blame됩니다. 격리 유틸 Fallback은 던지기 전에 만들어진 부분 트리를 회수하지 않고, OnRendered는 자기 서브트리 완성만 보장할 뿐 부모에 붙었음을 보장하지 않습니다.
  • 자세히: Quadnomicon Vol. 11 — 정적 grep 가능성, 표면 blame, 에러 아키텍처

인프라 매개변수가 없다는 것, 그리고 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 = "클릭하세요" }, -- 배열: 자식
}

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

Fusion Vide Quad
전파 모델 push 무효화 + pull 재계산 하이브리드. eager로 표시된 노드만 즉시 재계산하고, 글리치를 막으려 생성 순서로 정렬 순수 push — 즉시·동기·깊이우선으로 재평가. 다이아몬드 중복 재평가는 저자들이 미해결로 남김 push는 신호만, 재계산은 :Get() 시점 pull. 두 경로로 신호가 와도 재평가는 한 번
의존성 선언 명시적 use() 암묵적(전역 스코프 스택) — 리액티브 스코프 안 yield를 막는 별도 장치가 필요 명시적 :With / :Compute(fn, ...deps)
정리·스코프 모델 Scope 배열에 destroy 클로저를 모아 수동 티어다운. GC 비의존 의존성 엣지와 소유(owner/owned)를 분리한 수동 destroy. GC 비의존 스코프 객체 없음. 생존은 엔진 커넥션으로 판정하고 회수는 GC — 대신 Destroy가 유일한 절단면
마운트 소유권 이미 마운트된 인스턴스를 조건 없이 재부모화(가드 없음) 중복 마운트 체크 없음 — 같은 타깃에 두 번 마운트하면 루트가 둘 재마운트는 즉시 에러
키/값 디스패치 개방성 SpecialKey 모양은 열려 있으나 우선순위가 4단계 하드코딩 action(callback, priority) — 등록은 필요 없지만 key/value를 받지 않음 key·value·타깃을 다 받는 핸들러 + 열린 우선순위 공간

우위라고 말할 수 있는 것

  • 다이아몬드 의존성에서 중복 재평가가 구조적으로 생기지 않습니다 — Vide가 스스로 미해결로 남긴 자리입니다.
  • 이미 마운트된 것을 다시 마운트하면 즉시 에러가 납니다 — 두 라이브러리 다 이 가드가 없어 조용히 두 벌이 됩니다.
  • 바인드 핸들러의 우선순위 축이 열려 있습니다 — Fusion은 4단계 하드코딩이라 다섯 번째가 불가능하고, Vide는 key/value 없는 콜백뿐입니다.
  • 정리할 것을 담아 들고 다니는 스코프 객체가 없습니다 — 두 라이브러리는 둘 다 eager·수동 티어다운입니다.
  • 의존성이 명시적이라, 리액티브 스코프 안의 yield가 그래프를 깨는 버그 클래스가 없습니다 — Vide는 그걸 막는 별도 장치를 둡니다.
  • 리스트 key 관리·훅 호출 순서 규칙·고빈도 갱신용 우회 API 같은 vdom 특유의 문제가 없습니다(vs react-lua).

열위라고 말해야 하는 것

  • 실전 사용 이력이 없습니다. 세 라이브러리는 전부 실전에서 발견되고 고쳐진 문제들의 산물입니다.
  • Destroy가 유일한 회수 절단면이고, GC와 Destroying의 순서 비결정성을 그대로 안고 갑니다.
  • use-after-destroy 사전 검증 안전망이 없습니다.
  • 의존성을 손으로 전부 나열해야 하고, 그 목록은 실행 중에 바뀌지 않습니다.
  • 애니메이션 값을 다른 파생값의 입력으로 합성할 수 없습니다 — Fusion/Vide는 됩니다.
  • 지금 트리가 어떻게 생겼는지가 코드 한 곳에 드러나지 않습니다 — 렌더마다 다시 기술하는 vdom 쪽이 이 축에서 낫습니다.
  • 트리를 거슬러 올라가 조회하는 암묵 맥락이 없습니다 — q.Context는 손으로 넘기는 명시적 가방입니다.
  • 출하되는 렌더 백엔드가 quad-roblox 하나뿐이라, 백엔드 분리의 값은 아직 증명되지 않았습니다.