콘텐츠로 이동

컴포넌트 합성: 순수 함수와 경계 규약

난이도: 초급
다루는 개념: 컴포넌트 함수, 배열 부분의 or None 관용구, Modifier 우선순위, Tag/Attr

이 문서의 예제는 모두 아래 준비 코드를 앞에 둔 상태를 가정합니다.

-- 설치 경로는 프로젝트 구성에 따라 다르다(00-installation 참고)
local Quad = require(<quad-base 모듈 경로>)
local QuadRoblox = require(<quad-roblox 모듈 경로>).QuadRoblox
local q = Quad:UseProvider(QuadRoblox)
local D = q.D
local 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)를 주입할 수 있어야 합니다. 이때 지켜야 하는 관용구가 하나 있습니다.

Refq.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대 불변식”

컴포넌트가 자체 기본 디자인을 가지면서도 호출자의 커스텀 스타일을 수용해야 할 때가 있습니다. 병합 규칙은 셋뿐입니다.

  1. 해시 부분에 직접 적은 키가 이긴다: D.TextButton { props.Modifier or None, Text = "확정" }에서 Text는 어떤 Modifier가 무엇을 갖고 오든 그대로 유지됩니다. 평탄화는 이미 채워진 키를 건너뛰기 때문입니다.
  2. 배열 부분에서는 뒤에 온 Modifier가 이긴다: D.TextButton { BaseMod, props.Modifier or None }에서 뒤쪽 Modifier의 필드가 앞쪽을 덮습니다(역방향 스캔으로 마지막 것이 먼저 기록되고, 이미 기록된 키는 건너뜁니다).
  3. Modifier.Overridden(A, B)도 같은 방향: 명시적 합성에서도 뒤 인자(B)가 앞 인자(A)의 같은 필드를 덮습니다. 닷 형태 q.Modifier.Overridden(a, b)와 콜론 형태 a:Overridden(b) 둘 다 됩니다.

클래스 태그가 붙은 Modifier 팩토리

섹션 제목: “클래스 태그가 붙은 Modifier 팩토리”

Modifier는 단순한 딕셔너리가 아닙니다. 어떤 클래스에 적용 가능한지 타입에 실려 있어서, TextButton 전용 ModifierFrame의 배열 부분에 넣으면 타입 검사에서 걸립니다.

타입 이름 하나만 먼저: 아래 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,
}
end

D.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에서 BackgroundColor3Size는 덮어씌워지고, 기본 스타일의 TextColor3/TextSize는 그대로 남습니다.

상위 클래스 Modifier(예: D.Modifier.GuiObject { ... })까지 받고 싶다면 props.Modifier를 인터페이스 타입 DTypes.IntoTextButton?으로 선언하고, 꽂을 때 if props.Modifier then props.Modifier:AsTextButton() else None으로 내려받으세요.


컴포넌트가 자식을 유연하게 받으려면 배열 하나를 받아 그대로 펼치면 됩니다.

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 {}),
},
}
end

table.unpack(...)테이블 리터럴의 마지막 원소일 때만 전부 펼쳐집니다. 중간에 두면 첫 값 하나만 들어가니 주의하세요.

그리고 자식 배열을 담은 변수를 그대로 넘길 수는 없습니다D.Frame(children)은 타입이 맞지 않아 거부됩니다. props 테이블은 리터럴 자리에서만 추론이 살아 있으므로, 위처럼 D.Frame { table.unpack(children) } 형태로 마지막 원소에 펼쳐 넣으세요.


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,
},
}
end
  1. Tag는 합집합이다: 같은 태그를 여러 자리(컴포넌트, Modifier, State로 바뀌는 Tag 값 …)에서 요구해도, quad는 참조 카운트로 한 곳이라도 요구하는 동안 태그가 유지되도록 관리합니다. 마지막 요구가 사라질 때 비로소 태그가 제거됩니다.
  2. None이거나 State의 값이 nil이면 어트리뷰트가 삭제된다:
    • q.Attr { MyKey = None } — 즉시 삭제(inst:SetAttribute(key, nil)).
    • 바인딩된 State의 값이 nil이 된 경우도 마찬가지로 삭제됩니다. 단일 키 경로는 값을 그대로 엔진에 넘기고, nil도 예외가 아닙니다.
    • 반면 그룹 테이블에 nil을 직접 적는 것(q.Attr { MyKey = nil })은 애초에 항목이 생기지 않는 것과 같습니다 — 지우려는 뜻이면 None을 쓰세요.
  3. 다만 ‘그룹 자체가 교체되어 이름이 사라진’ 경우는 값이 남는다: Attr 그룹 값을 통째로 다른 그룹으로 바꿔서 어떤 이름이 새 그룹에 없어졌다면, 그 이름의 구독만 끊기고 인스턴스에 이미 찍혀 있던 값은 그대로 남습니다. 그룹을 물릴 때 엔진에 지우기를 요청하지는 않기 때문입니다. 지우고 싶으면 새 그룹에서 그 이름을 None으로 명시하세요.
  4. 한 이름은 한 주인만: 어떤 이름을 그룹이 잡고 있는데 다른 자리에서 같은 이름을 직접 쓰려 하면 그 자리에서 에러가 납니다.

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여도 됩니다.


  • 컴포넌트는 1회 실행되는 셋업 함수인가?
  • 외부에서 주입받는 Modifier/Ref배열 부분에 놓고 or None으로 nil-hole을 막았는가?
  • 해시 키 > 후행 Modifier > 선행 Modifier 우선순위를 알고 설계했는가?
  • Attr 값을 지울 때 None(또는 State의 nil)을 쓰고, 그룹 교체만으로는 값이 안 지워진다는 걸 아는가?
  • 반응형 로직을 담은 헬퍼 함수를 React Hook의 제약 없이 자유롭게 분리했는가?