컴포넌트 합성: 순수 함수와 경계 규약
난이도: 초급
다루는 개념: 컴포넌트 함수, 배열 부분의or None관용구,Modifier우선순위,Tag/Attr
이 문서의 예제는 모두 아래 준비 코드를 앞에 둔 상태를 가정합니다.
-- 설치 경로는 프로젝트 구성에 따라 다르다(00-installation 참고)local Quad = require(<quad-base 모듈 경로>)local QuadRoblox = require(<quad-roblox 모듈 경로>).QuadRobloxlocal q = Quad:UseProvider(QuadRoblox)local D = q.Dlocal None = q.None
-- 값이 아니라 '타입 이름'을 가져오는 모듈local QuadTypes = require(<quad-types 모듈 경로>) -- State/Source/Ref/StateData …-- 클래스별 Modifier·요소 타입(`TextButtonModifier`/`IntoTextButton`/`FrameElem` 등)은 생성된 D 모듈에서local DTypes = require(<quad-roblox D 모듈 경로>)아래 예제에서 DTypes로 쓰는 클래스별 타입(TextButtonModifier, IntoTextButton, FrameElem …)은 quad-roblox의 생성 D 모듈에 들어 있습니다 — 가져오는 경로는 설치 구성에 따라 다릅니다(00-installation 참고).
1. 컴포넌트는 단지 ’순수 함수’일 뿐이다
섹션 제목: “1. 컴포넌트는 단지 ’순수 함수’일 뿐이다”Quad에서 컴포넌트는 특수한 클래스나 매크로가 아닙니다.
Props 테이블을 입력받아 실제 Instance를 반환하는 평범한 Luau 함수입니다.
-- 가장 단순한 Quad 컴포넌트local function Card(props: { read Text: string }): Frame return D.Frame { Size = UDim2.fromOffset(200, 100), BackgroundColor3 = Color3.fromRGB(40, 40, 45), UICorner = 8,
D.TextLabel { Text = props.Text, TextColor3 = Color3.fromRGB(255, 255, 255), }, }end철학: 마법은 없다 (No Magic)
컴포넌트가 뒤에서 몰래 전역 상태를 만들거나 부모의 라이프사이클을 가로채지 않습니다. 필요한 것은 전부props로 명시적으로 들어옵니다.
트리를 거슬러 올라가 값을 찾아 주는 장치도 없습니다 — 부모가 가진 값이 자식에게 저절로 내려오는 경로는 없고, 계층을 건너뛰어 값을 넘기고 싶으면q.Context로 명시적으로 넘깁니다(05. 디자인 토큰과 테마 전환 참고).
2. 컴포넌트 경계 규약: 배열 부분의 or None
섹션 제목: “2. 컴포넌트 경계 규약: 배열 부분의 or None”재사용 가능한 컴포넌트는 호출자가 스타일(Modifier)이나 내부 인스턴스 참조(Ref)를 주입할 수 있어야 합니다. 이때 지켜야 하는 관용구가 하나 있습니다.
Ref는 q.Ref(nil)로 만들어 배열 부분에 놓아 두면 quad가 만들어진 인스턴스를 채워 주는 빈 상자입니다 — 나중에 ref.Value로 꺼내 씁니다.
local function MaterialButton(props: { read Text: string?, read Modifier: DTypes.TextButtonModifier?, read Ref: QuadTypes.Ref<TextButton?>? }): TextButton return D.TextButton { props.Modifier or None, -- ⭐ 배열 부분에서만 의미가 있는 관용구 props.Ref or None,
Text = props.Text or "", }end왜 or None인가, 그리고 왜 하필 배열 부분인가
섹션 제목: “왜 or None인가, 그리고 왜 하필 배열 부분인가”Modifier·Ref·Slot·Observer·Effect·Tag·Attr 같은 값은 해시 키가 아니라 배열 부분에 놓습니다. 해시 값 자리에 Modifier를 두면 디스패치가 거부합니다.
그런데 배열 리터럴 안의 표현식이 nil로 평가되면 그 자리에 구멍(nil-hole) 이 생깁니다. 구멍이 있는 배열은 #도 순회 순서도 보장되지 않습니다.
그래서 quad는 구멍 있는 props 테이블을 계약 밖(UB) 으로 둡니다. 무슨 일이 나는지는 구멍이 어디에 뚫렸느냐에 따라 갈리는데, 그 증상과 에러 메시지는 01. quad 에러 읽는 법과 런타임 디버깅의 함정 1에 정리돼 있습니다.
-- ❌ props.Modifier가 없으면 1번 자리가 구멍이 된다 — 순회가 그 자리를 건너뛴다D.TextButton { props.Modifier, props.Ref, Text = "x" }
-- ✅ None이 자리를 지킨다 — 기여는 0이지만 위치는 그대로D.TextButton { props.Modifier or None, props.Ref or None, Text = "x" }None은 “여기에 아무것도 없다”를 뜻하는 명시적 센티널입니다. 자리를 유지하되 아무것도 기여하지 않으므로, 호출자가 Modifier만 생략하든 Ref만 생략하든 나머지 원소는 원래 위치 그대로 꽂힙니다.
props.Modifier/props.Ref/props.children이라는 이름은 이 문서가 따르는 관례이고, 언어나 엔진이 강제하는 것은 아닙니다. 참고로Slot을 반환하는 컴포넌트에는 이 파라미터들이 없습니다 — 꽂을 루트 인스턴스가 없기 때문입니다.Slot은 자식이 들어갈 자리를 배열 부분에 잡아 두고 그 구간의 요소를 quad가 관리하게 하는 값입니다(03.Slot:List로 긴 목록 다루기 참고).
3. 스타일 합성 및 우선순위 3대 불변식
섹션 제목: “3. 스타일 합성 및 우선순위 3대 불변식”컴포넌트가 자체 기본 디자인을 가지면서도 호출자의 커스텀 스타일을 수용해야 할 때가 있습니다. 병합 규칙은 셋뿐입니다.
- 해시 부분에 직접 적은 키가 이긴다:
D.TextButton { props.Modifier or None, Text = "확정" }에서Text는 어떤Modifier가 무엇을 갖고 오든 그대로 유지됩니다. 평탄화는 이미 채워진 키를 건너뛰기 때문입니다. - 배열 부분에서는 뒤에 온
Modifier가 이긴다:D.TextButton { BaseMod, props.Modifier or None }에서 뒤쪽Modifier의 필드가 앞쪽을 덮습니다(역방향 스캔으로 마지막 것이 먼저 기록되고, 이미 기록된 키는 건너뜁니다). Modifier.Overridden(A, B)도 같은 방향: 명시적 합성에서도 뒤 인자(B)가 앞 인자(A)의 같은 필드를 덮습니다. 닷 형태q.Modifier.Overridden(a, b)와 콜론 형태a:Overridden(b)둘 다 됩니다.
클래스 태그가 붙은 Modifier 팩토리
섹션 제목: “클래스 태그가 붙은 Modifier 팩토리”Modifier는 단순한 딕셔너리가 아닙니다. 어떤 클래스에 적용 가능한지 타입에 실려 있어서, TextButton 전용 Modifier를 Frame의 배열 부분에 넣으면 타입 검사에서 걸립니다.
타입 이름 하나만 먼저: 아래
props처럼 반응형 값을 받는 입력 자리에는QuadTypes.StateMarker<T>를 씁니다. 컴포넌트 안에서 만든 지역 변수나 반환 타입처럼 메소드를 실제로 부르는 자리는QuadTypes.State<T>그대로입니다.
type ButtonProps = { read Text: string | QuadTypes.StateMarker<string>, read OnClick: () -> (), read Modifier: DTypes.TextButtonModifier?, read Ref: QuadTypes.Ref<TextButton?>?,}
local function CustomButton(props: ButtonProps): TextButton -- [1] TextButton 전용으로 태그된 기본 스타일 local baseStyle = D.Modifier.TextButton { Size = UDim2.fromOffset(140, 40), BackgroundColor3 = Color3.fromRGB(0, 140, 240), TextColor3 = Color3.fromRGB(255, 255, 255), TextSize = 16, }
return D.TextButton { -- [2] 불변식 2 — 뒤에 온 props.Modifier가 baseStyle을 필드 단위로 덮는다 baseStyle, props.Modifier or None, props.Ref or None,
-- [3] 불변식 1 — 해시 키는 어떤 Modifier도 덮지 못한다 Text = props.Text, MouseButton1Click = props.OnClick, UICorner = 8, }endD.Modifier.<Class>는 위처럼 테이블을 넘기는 형태와 빌더 체인 형태(D.Modifier.TextButton():TextSize(16):Text("x")) 둘 다 지원합니다.
사용하는 쪽에서의 유연성
섹션 제목: “사용하는 쪽에서의 유연성”-- 기본 스타일 그대로local btn1 = CustomButton { Text = "확인", OnClick = function() print("기본 버튼") end }
-- 크기와 색상만 위험 버튼으로 커스텀local btn2 = CustomButton { Text = "삭제", OnClick = function() print("삭제 버튼") end, Modifier = D.Modifier.TextButton { BackgroundColor3 = Color3.fromRGB(240, 60, 60), Size = UDim2.fromOffset(100, 36), },}케이스 B에서 BackgroundColor3와 Size는 덮어씌워지고, 기본 스타일의 TextColor3/TextSize는 그대로 남습니다.
상위 클래스
Modifier(예:D.Modifier.GuiObject { ... })까지 받고 싶다면props.Modifier를 인터페이스 타입DTypes.IntoTextButton?으로 선언하고, 꽂을 때if props.Modifier then props.Modifier:AsTextButton() else None으로 내려받으세요.
4. 자식 요소(Children) 전달하기
섹션 제목: “4. 자식 요소(Children) 전달하기”컴포넌트가 자식을 유연하게 받으려면 배열 하나를 받아 그대로 펼치면 됩니다.
local function ModalDialog(props: { read Title: string, read children: { DTypes.FrameElem }? }): Frame return D.Frame { Size = UDim2.fromOffset(400, 300), BackgroundColor3 = Color3.fromRGB(25, 25, 30),
-- 헤더 타이틀 D.TextLabel { Text = props.Title, Size = UDim2.new(1, 0, 0, 40), },
-- 전달받은 자식들을 컨텐츠 영역에 펼친다 D.Frame { Position = UDim2.fromOffset(0, 40), Size = UDim2.new(1, 0, 1, -40), BackgroundTransparency = 1,
table.unpack(props.children or {}), }, }endtable.unpack(...)은 테이블 리터럴의 마지막 원소일 때만 전부 펼쳐집니다. 중간에 두면 첫 값 하나만 들어가니 주의하세요.
그리고 자식 배열을 담은 변수를 그대로 넘길 수는 없습니다 — D.Frame(children)은 타입이 맞지 않아 거부됩니다. props 테이블은 리터럴 자리에서만 추론이 살아 있으므로, 위처럼 D.Frame { table.unpack(children) } 형태로 마지막 원소에 펼쳐 넣으세요.
5. 선언적 메타데이터: Tag와 Attr
섹션 제목: “5. 선언적 메타데이터: Tag와 Attr”Roblox의 CollectionService 태그와 인스턴스 어트리뷰트도 배열 부분에서 선언적으로 다룰 수 있습니다.
local function CharacterBadge(props: { read Name: string, read Level: QuadTypes.StateMarker<number> }): Frame return D.Frame { -- [1] CollectionService 태그 (여러 자리의 요구를 합집합으로 관리) q.Tag("PlayerBadge", "Interactable"),
-- [2] 어트리뷰트 그룹 — 값은 리터럴이어도 State여도 된다 q.Attr { CharacterName = props.Name, Level = props.Level, Guild = "Alpha", },
D.TextLabel { Text = props.Name, }, }endTag와 Attr에서 알아둘 규칙
섹션 제목: “Tag와 Attr에서 알아둘 규칙”Tag는 합집합이다: 같은 태그를 여러 자리(컴포넌트,Modifier, State로 바뀌는Tag값 …)에서 요구해도, quad는 참조 카운트로 한 곳이라도 요구하는 동안 태그가 유지되도록 관리합니다. 마지막 요구가 사라질 때 비로소 태그가 제거됩니다.None이거나 State의 값이nil이면 어트리뷰트가 삭제된다:q.Attr { MyKey = None }— 즉시 삭제(inst:SetAttribute(key, nil)).- 바인딩된
State의 값이nil이 된 경우도 마찬가지로 삭제됩니다. 단일 키 경로는 값을 그대로 엔진에 넘기고,nil도 예외가 아닙니다. - 반면 그룹 테이블에
nil을 직접 적는 것(q.Attr { MyKey = nil })은 애초에 항목이 생기지 않는 것과 같습니다 — 지우려는 뜻이면None을 쓰세요.
- 다만 ‘그룹 자체가 교체되어 이름이 사라진’ 경우는 값이 남는다:
Attr그룹 값을 통째로 다른 그룹으로 바꿔서 어떤 이름이 새 그룹에 없어졌다면, 그 이름의 구독만 끊기고 인스턴스에 이미 찍혀 있던 값은 그대로 남습니다. 그룹을 물릴 때 엔진에 지우기를 요청하지는 않기 때문입니다. 지우고 싶으면 새 그룹에서 그 이름을None으로 명시하세요. - 한 이름은 한 주인만: 어떤 이름을 그룹이 잡고 있는데 다른 자리에서 같은 이름을 직접 쓰려 하면 그 자리에서 에러가 납니다.
6. 재사용 로직 추출: Hook 규칙의 족쇄가 없는 팩토리
섹션 제목: “6. 재사용 로직 추출: Hook 규칙의 족쇄가 없는 팩토리”React에서는 use* Hook을 조건문이나 루프 안에서 호출하면 “Rules of Hooks” 위반으로 런타임 에러가 납니다.
Quad의 컴포넌트는 매 프레임 재실행되지 않는 1회성 셋업 함수이므로, 반응형 로직을 담은 함수를 어떤 조건문이나 루프 안에서도 자유롭게 부를 수 있습니다. 이름 규칙도 따로 없습니다.
local function newCounter(initial: number) local count = q.Source(initial) -- --!strict에서는 :Compute 콜백 파라미터에 주석이 필요하다 local isEven = count:Compute(function(c: QuadTypes.StateData<number>): boolean return c:Get() % 2 == 0 end)
local function increment() count:Set(count:Get() + 1) end
return { count = count, isEven = isEven, increment = increment }end
-- 컴포넌트 안에서, 조건문 안에서, 루프 안에서 아무 제약 없이 호출 가능local left, right = newCounter(0), newCounter(10)[!TIP] 상태 위에 얹는 연산 조합자는
q.Operator네임스페이스에 있고,:Apply로 붙입니다 — 예:price:Apply(q.Operator.Sum(tax, shipping)),reduceMotion:Apply(q.Operator.Not). 인자는 리터럴이어도 State여도 됩니다.
7. 컴포넌트 작성 체크리스트
섹션 제목: “7. 컴포넌트 작성 체크리스트”- 컴포넌트는 1회 실행되는 셋업 함수인가?
- 외부에서 주입받는
Modifier/Ref를 배열 부분에 놓고or None으로 nil-hole을 막았는가? - 해시 키 > 후행
Modifier> 선행Modifier우선순위를 알고 설계했는가? -
Attr값을 지울 때None(또는 State의nil)을 쓰고, 그룹 교체만으로는 값이 안 지워진다는 걸 아는가? - 반응형 로직을 담은 헬퍼 함수를 React Hook의 제약 없이 자유롭게 분리했는가?