Skip to content

01. 컴포넌트 경계 규약과 스타일 합성

This content is not available in your language yet.

대상 독자: 재사용 가능한 컴포넌트를 만들어 여러 화면에 나눠 쓰려는 개발자 다루는 개념: props 테이블의 병합 규칙, 숫자 키 자리의 or None 관용구, Modifier·Spread 우선순위, 값을 넘기는 다섯 가지 길, Tag/Attr, Hook 규칙 없는 팩토리

시작하기 12. 컴포넌트로 쪼개기에서 컴포넌트가 평범한 함수라는 것을, 10. 스타일을 값으로 들고 다니기에서 스타일을 숫자 키 자리에 놓는다는 것을 봤습니다. 이 문서는 그 경계에서 지켜야 하는 규약을 모아 둔 곳입니다.

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

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")
local None = q.None
-- 값이 아니라 '타입 이름'을 가져오는 모듈
local QuadTypes = require("@game/ReplicatedStorage/roblox_packages/quad_types") -- 설정 모듈이 다시 내보내지 않는 타입(`QuadTypes.StateMarker<T>` 등)
-- 클래스를 인자로 받는 타입(`Modifier<C>`/`ModifierInto<C>`/`SpreadInto<C>`/`PropsArrayItem<C>` 등)은 quad_roblox 패키지 루트가 내보낸다
local RobloxModule = require("@game/ReplicatedStorage/roblox_packages/quad_roblox")

아래 예제에서 RobloxModule로 쓰는 타입(Modifier<C>, ModifierInto<C>, SpreadInto<C>, PropsArrayItem<C> …)은 quad_roblox 패키지 루트가 그대로 내보내므로 설치 방식과 무관하게 이 경로로 닿습니다(전체 목록은 레퍼런스: 설치의 RobloxExtension 절).


1. props 테이블의 두 부분이 지키는 규칙 넷

섹션 제목: “1. props 테이블의 두 부분이 지키는 규칙 넷”

q.Elem "Frame" { ... }에 넘기는 테이블은 단순한 설정 딕셔너리가 아닙니다. 문자 키와 숫자 키가 각각 다른 뜻을 갖습니다.

local isHovered = q.Source(false)
local CommonButtonModifier = q.Modifier "Frame" {
BorderSizePixel = 0,
}
q.Elem "Frame" {
-- [1] 문자 키: 프로퍼티 바인딩 (정적 값 또는 반응형 State/Tween)
Size = UDim2.new(0, 200, 0, 50),
BackgroundColor3 = isHovered:Compute(function(h) -- 편집기 타입 검사까지 보려면 지역 변수로 빼세요(02 §4)
return if h:Get() then Color3.fromRGB(80, 120, 240) else Color3.fromRGB(50, 50, 60)
end),
-- [2] 문자 키: 이벤트 리스너 (엔진이 주는 인자만 받는다 — self는 안 온다)
MouseEnter = function() isHovered:Set(true) end,
MouseLeave = function() isHovered:Set(false) end,
-- [3] 숫자 키: 재사용 가능한 스타일 (Modifier)
CommonButtonModifier,
-- [4] 숫자 키: 자식 요소
q.Elem "TextLabel" {
Text = "클릭하세요",
},
}
  1. 숫자 키: 자식 인스턴스, Modifier, Spread, Ref/PreRef/PostRef, Slot, Observer/Effect, Tag/Attr, q.OnChange "Class" "Name" (...)·q.OnEvent가 들어가는 자리입니다. 순서가 의미를 갖습니다 — 뒤에 온 Modifier가 앞의 것을 필드 단위로 덮고, Spread는 거꾸로 먼저 자리 잡은 것이 이깁니다(§3). 자식 전용 키는 따로 없습니다 — 이름 없는 자리(숫자 키)가 곧 자식입니다.
  2. 문자 키: 프로퍼티·이벤트가 각자 전용 핸들러를 통해 인스턴스에 바인딩됩니다. 문자 키에 직접 적은 프로퍼티는 숫자 키의 어떤 Modifier보다 우선합니다. 다만 UICorner/UIPadding/UIPaddingOffset/UIScale 네 키는 프로퍼티가 아니라 관리 자식을 만드는 숏핸드입니다(quad가 그 자리에 UICorner 같은 자식을 만들어 붙이고 관리합니다).
  3. 한 번에 처리된다: 숫자 키 자리가 있으면 그 전체가 하나의 배치로 묶여 재계산이 끝에 한 번 일어납니다. 그 안에서 어떤 핸들러가 어떤 순서로 매칭되는지는 Quadnomicon Vol. 8 — 확장 가능한 디스패치 엔진이 다룹니다.
  4. 정리(Teardown): 인스턴스를 Destroy()하면 거기 묶인 구독과 트윈은 더 이상 실행되지 않습니다. Quad는 인스턴스마다 걸어 둔 엔진 연결이 끊겼는지로 생존을 판정하고, 실제 메모리 회수는 Luau GC에 맡깁니다. 수동으로 disconnect할 것은 없고, 정리해야 할 것들을 담아 들고 다니는 스코프 객체도 없습니다(Fusion의 Scope, Vide의 소유 스코프 자리에 해당하는 것이 quad에는 없습니다).

2. 컴포넌트 경계 규약: 숫자 키 자리의 or None

섹션 제목: “2. 컴포넌트 경계 규약: 숫자 키 자리의 or None”

재사용 가능한 컴포넌트는 호출자가 스타일(Modifier)이나 내부 인스턴스 참조(Ref)를 주입할 수 있어야 합니다. 이때 지켜야 하는 관용구가 하나 있습니다.

Ref는 q.Ref<<TextButton?>>(nil)처럼 담길 타입을 적어 만들어 숫자 키 자리에 놓아 두면 quad가 만들어진 인스턴스를 채워 주는 빈 상자입니다 — 나중에 ref.Value로 꺼내 씁니다(타입 인자를 빼면 Ref<nil>이 되어 그 자리에서 막힙니다 — 시작하기 07).

local function MaterialButton(props: { read Text: string?, read Modifier: RobloxModule.ModifierInto<TextButton>?, read Ref: q.Ref<TextButton?>? }): TextButton
return q.Elem "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는 그 자리에서 에러를 냅니다(Quad0023 Bookkeeping.recompute: sourceList[N] is nil - a nil hole …). 조용히 넘어가는 것은 꼬리 구멍뿐이고, 그마저 뒤에 원소를 하나 더 붙이는 순간 에러가 됩니다.

그래서 구멍 있는 props 테이블은 계약 밖입니다 — 꼬리 구멍이 통과하는 것은 우연이지 보장이 아닙니다. 증상과 에러 메시지는 10. 부록 — quad 에러 읽는 법의 함정 1에 정리돼 있습니다.

-- ❌ props.Modifier가 없으면 1번 자리가 구멍이 된다 — 뒤에 값(props.Ref)이 오면 그 자리에서 에러, 둘 다 없으면 꼬리 구멍이라 우연히 통과(어느 쪽도 계약 밖)
q.Elem "TextButton" { props.Modifier, props.Ref, Text = "x" }
-- ✅ None이 자리를 지킨다 — 기여는 0이지만 위치는 그대로
q.Elem "TextButton" { props.Modifier or None, props.Ref or None, Text = "x" }

None은 “여기에 아무것도 없다”를 뜻하는 명시적 센티널입니다. 자리를 유지하되 아무것도 기여하지 않으므로, 호출자가 Modifier만 생략하든 Ref만 생략하든 나머지 원소는 원래 위치 그대로 꽂힙니다. 센티널 전체는 레퍼런스: 생명주기와 센티널에 있습니다.

그리고 받은 값을 숫자 키 자리에 놓지 않으면 그냥 사라집니다. 호출자가 넘긴 props.Modifier를 컴포넌트가 어느 자리에도 놓지 않으면 그 Modifier는 아무 일도 하지 않고 조용히 버려집니다 — quad에는 “넘겨받고 안 쓴 값”을 잡아 주는 방어가 없어서 경고도 에러도 나지 않습니다. 호출자가 스타일을 줬는데 화면이 그대로라면 이 자리를 먼저 보세요.

props.Modifier / props.Ref / props.Children이라는 이름은 이 문서가 따르는 관례이고, 언어나 엔진이 강제하는 것은 아닙니다. 참고로 Slot을 반환하는 컴포넌트에는 이 파라미터들이 없습니다 — 꽂을 루트 인스턴스가 없기 때문입니다. Slot은 자식이 들어갈 자리를 숫자 키 자리에 잡아 두고 그 구간의 요소를 quad가 관리하게 하는 값입니다(시작하기 11. 자식이 들어갈 자리 참고).


3. 스타일 합성 및 우선순위 3대 불변식

섹션 제목: “3. 스타일 합성 및 우선순위 3대 불변식”

컴포넌트가 자체 기본 디자인을 가지면서도 호출자의 커스텀 스타일을 수용해야 할 때가 있습니다. 병합 규칙은 셋뿐입니다(Modifier 값 자체의 표면은 레퍼런스: Modifier, 클래스별 생성자는 레퍼런스: q.Modifier에 있습니다).

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

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

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

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

타입 이름 하나만 먼저: 아래 props처럼 반응형 값을 받는 입력 자리에는 StateMarker<T>를, 컴포넌트 안에서 만든 지역 변수나 반환 타입처럼 메소드를 실제로 부르는 자리는 State<T>를 씁니다. 둘은 같은 quad-types에서 오지만, 설정 모듈이 다시 내보내는 아홉에 State는 있고 StateMarker는 없어서 이 문서에서는 각각 q.State<T>·QuadTypes.StateMarker<T>로 적힙니다.

type ButtonProps = {
read Text: string | QuadTypes.StateMarker<string>,
read OnClick: () -> (),
read Modifier: RobloxModule.ModifierInto<TextButton>?, -- 입력 자리 마커: TextButton과 그 조상 클래스의 Modifier
read Ref: q.Ref<TextButton?>?,
}
local function CustomButton(props: ButtonProps): TextButton
-- [1] TextButton 전용으로 태그된 기본 스타일
local baseStyle = q.Modifier "TextButton" {
Size = UDim2.fromOffset(140, 40),
BackgroundColor3 = Color3.fromRGB(0, 140, 240),
TextColor3 = Color3.fromRGB(255, 255, 255),
TextSize = 16,
}
return q.Elem "TextButton" {
-- [2] 불변식 2 — 뒤에 온 props.Modifier가 baseStyle을 필드 단위로 덮는다
baseStyle,
props.Modifier or None,
props.Ref or None,
-- [3] 불변식 1 — 문자 키는 어떤 Modifier도 덮지 못한다
Text = props.Text,
MouseButton1Click = props.OnClick, -- 마우스 좌클릭만 받는 엔진 이벤트 — 게임패드·터치까지 받으려면 시작하기의 Activated
UICorner = 8,
}
end

q.Modifier "Class"는 위처럼 테이블을 넘기는 형태와 빌더 체인 형태(q.Modifier "TextButton" ():TextSize(16):Text("x")) 둘 다 지원합니다. 다만 타입 검사는 체인 형태에서만 삽니다 — 테이블 형태의 필드는 이름도 값 타입도 검사되지 않고({ Sise = 1 }도 통과), 틀린 것은 런타임 drive에서 Quad0076류로 잡힙니다. strict의 도움을 받고 싶으면 체인 형태로 쓰세요(Modifier 레퍼런스).

-- 기본 스타일 그대로
local btn1 = CustomButton { Text = "확인", OnClick = function() print("기본 버튼") end }
-- 크기와 색상만 위험 버튼으로 커스텀
local btn2 = CustomButton {
Text = "삭제",
OnClick = function() print("삭제 버튼") end,
Modifier = q.Modifier "TextButton" {
BackgroundColor3 = Color3.fromRGB(240, 60, 60),
Size = UDim2.fromOffset(100, 36),
},
}

케이스 B에서 BackgroundColor3와 Size는 덮어씌워지고, 기본 스타일의 TextColor3/TextSize는 그대로 남습니다.

ModifierInto<TextButton>은 입력 자리 마커입니다 — TextButton과 그 조상 클래스(q.Modifier "GuiObject" { ... } 등)의 Modifier를 받고, 형제 클래스(q.Modifier "TextLabel")의 것은 타입에서 거부합니다. 받은 값은 그대로 숫자 키 자리에 놓으면 됩니다. 마커에는 메소드가 없으니, 받은 Modifier에 setter를 더 불러야 하는 컴포넌트라면 RobloxModule.Modifier<TextButton>?으로 받으세요(그때 조상 클래스 값은 부르는 쪽이 :AsTextButton()으로 내려서 넘깁니다).

안쪽 요소에 무엇이든 더 꽂을 자리 — Spread

섹션 제목: “안쪽 요소에 무엇이든 더 꽂을 자리 — Spread”

Modifier는 프로퍼티 필드만 담습니다. 컴포넌트 안쪽 요소에 호출자가 이벤트 핸들러·Ref·q.OnChange 디스크립터·자식까지 더 꽂게 하려면 Spread를 받습니다. Spread는 요소 테이블과 같은 모양으로 적은 조각이고, 숫자 키 자리에 놓으면 그 자리에 펼쳐집니다.

local function SearchBox(props: { read Placeholder: string?, read FieldSpread: RobloxModule.SpreadInto<TextBox>? })
return q.Elem "Frame" {
Size = UDim2.fromOffset(240, 32),
q.Elem "TextBox" {
props.FieldSpread or None, -- 호출자가 넘긴 조각이 이 자리에 펼쳐진다
PlaceholderText = props.Placeholder or "",
ClearTextOnFocus = false,
},
}
end
local focused = q.Source(false)
local box = SearchBox {
FieldSpread = q.Spread "TextBox" {
q.OnEvent "TextBox" "Focused" (function() focused:Set(true) end),
q.OnEvent "TextBox" "FocusLost" (function() focused:Set(false) end),
TextSize = 18,
},
}

같은 키가 여러 곳에 있으면 순위는 이렇습니다.

순위 어디에 적힌 키 규칙
1 요소 테이블에 직접 적은 문자 키 항상 이긴다
2 Spread가 가져온 키 먼저 자리 잡은 Spread가 이긴다
3 Modifier가 가져온 키 뒤에 오는 Modifier가 이긴다

Spread의 “먼저가 이긴다”는 JavaScript의 객체 펼치기({...a, ...b} — 뒤가 이김)와 반대입니다. Luau 테이블 생성자는 문자 키와 배열 값 사이의 순서를 남기지 않아서, 직접 적은 키를 언제나 맨 앞 층으로 두려면 “먼저 자리 잡은 것이 이긴다”여야 합니다. 그래서 위 SearchBox의 ClearTextOnFocus = false는 호출자가 바꿀 수 없고, 호출자의 TextSize = 18은 안쪽에 같은 키가 없으니 들어갑니다. 같은 이벤트를 컴포넌트와 호출자가 둘 다 들어야 한다면 문자 키(Focused = fn — 하나만 남음) 대신 위처럼 q.OnEvent 디스크립터를 쓰세요 — 디스크립터는 배열 값이라 서로 밀어내지 않습니다.

컴포넌트가 자기 조각에 호출자의 조각을 덧붙이려면 spread:Append(other)를 씁니다(아무것도 돌려주지 않습니다). 요소에 놓이거나 다른 Spread에 펼쳐진 Spread는 잠겨 더 이상 :Append할 수 없습니다 — 인스턴스마다 새로 만드세요.

무엇으로 넘길까 — Store·Context·Modifier·Spread·토큰

섹션 제목: “무엇으로 넘길까 — Store·Context·Modifier·Spread·토큰”

다섯 다 “값을 어딘가로 넘긴다”처럼 보이지만 하는 일이 다릅니다.

  • Store — :Set할 수 있는 필드들의 묶음입니다. 상태의 원천이고, 한 필드를 다른 필드에서 파생시키지는 않습니다.
  • Context — 트리 아래로 명시적으로 넘기는 값 가방입니다. 깊이 있는 컴포넌트까지 props를 줄줄이 넘기지 않으려고 씁니다.
  • Modifier — 요소 하나의 프로퍼티를 덮는 불변 값입니다. 만들어 두고 여러 곳에 놓습니다. 인라인 키를 이기지 못합니다.
  • Spread — 요소 테이블 조각입니다. 프로퍼티뿐 아니라 이벤트·Ref·디스크립터·자식까지 담고, 한 번 놓이면 끝나는 일회용 값입니다.
  • 토큰 묶음 — 색·간격 같은 디자인 값을 State로 쥐고, 토큰이 다른 토큰에서 계산되는 묶음입니다(CSS 변수에 가깝습니다). quad에 전용 원시 기능은 아직 없고, 레시피는 05. 디자인 토큰과 테마 전환에 있습니다.

고르는 기준은 셋입니다. 상태를 바꿀 원천이면 Store를 씁니다. 트리 아래로 멀리 보낼 값이면 Context를 씁니다. 특정 요소에 꽂을 것이면 둘 중 하나를 씁니다 — 프로퍼티만이고 재사용할 값이면 Modifier, 핸들러·Ref·자식까지 섞인 한 번짜리면 Spread입니다.


4. 자식을 받는 컴포넌트에 타입 붙이기

섹션 제목: “4. 자식을 받는 컴포넌트에 타입 붙이기”

호출자가 넣을 자식은 Slot 하나로 받습니다(시작하기 11. 자식이 들어갈 자리). 그 자리에 타입을 붙이면 이렇게 됩니다.

local function ModalDialog(props: { read Title: string, read Children: q.Slot<Instance>? }): Frame
return q.Elem "Frame" {
Size = UDim2.fromOffset(400, 300),
BackgroundColor3 = Color3.fromRGB(25, 25, 30),
-- 헤더 타이틀
q.Elem "TextLabel" {
Text = props.Title,
Size = UDim2.new(1, 0, 0, 40),
},
-- 전달받은 Slot을 컨텐츠 영역의 한 자리로 놓는다
q.Elem "Frame" {
Position = UDim2.fromOffset(0, 40),
Size = UDim2.new(1, 0, 1, -40),
BackgroundTransparency = 1,
props.Children or None,
},
}
end

부르는 쪽은 ModalDialog { Title = "설정", Children = q.Slot<<Instance>>({ ... }) }처럼 넘깁니다. Slot으로 받으면 호출자가 나중에 :Add/:Remove로 내용을 갈아 끼울 수 있고, 컴포넌트 쪽은 자리 하나만 잡아 두면 됩니다.

자식 배열을 그대로 넘기지 않는 이유

섹션 제목: “자식 배열을 그대로 넘기지 않는 이유”

props에 { RobloxModule.PropsArrayItem<Frame> } 같은 배열을 받아 펼치는 모양도 문법상으로는 가능하지만, 두 가지 제약이 따라옵니다.

  • table.unpack(...)은 테이블 리터럴의 마지막 원소일 때만 전부 펼쳐집니다. 중간에 두면 첫 값 하나만 들어갑니다.
  • 자식 배열을 담은 변수를 props 테이블 자리에 그대로 넘길 수는 없습니다 — q.Elem "Frame" (children)은 타입이 맞지 않아 거부됩니다. props 테이블은 리터럴 자리에서만 추론이 살아 있습니다.

그래서 이 문서는 자식 전달을 Slot으로 통일합니다(요소 테이블 조각을 통째로 넘기고 싶다면 위의 Spread입니다). PropsArrayItem<C> 타입 자체는 여전히 유용합니다 — 그 클래스의 숫자 키 자리에 올 수 있는 것들의 유니언이라, 자식 Instance뿐 아니라 Modifier·Spread·Ref·Slot 같은 디스크립터도 들어 있습니다.

반대로 컴포넌트가 형제 여럿을 돌려줘야 할 때도 Slot입니다. 래퍼 Frame을 만드는 대신 Slot 하나를 돌려주면, 부르는 쪽은 그 반환값을 숫자 키 자리에 그대로 놓습니다.

local function Rows(props: { read Items: { string } })
local rows = q.Slot<<Instance>>()
for _, text in props.Items do
rows:Add(q.Elem "TextLabel" { Text = text })
end
return rows
end
local list = q.Elem "Frame" {
q.Elem "TextLabel" { Text = "머리" },
Rows { Items = { "a", "b" } }, -- 자식 셋: 머리 + a + b
}

Roblox의 CollectionService 태그와 인스턴스 어트리뷰트도 숫자 키 자리에서 선언적으로 다룰 수 있습니다(기본은 시작하기 05. 이름표와 속성에서 배웁니다 — 여기서는 컴포넌트 경계에서 지킬 규칙만 봅니다. 두 값의 전체 표면은 레퍼런스: Tag / Attr에 있습니다).

local function CharacterBadge(props: { read Name: string, read Level: QuadTypes.StateMarker<number> }): Frame
return q.Elem "Frame" {
-- [1] CollectionService 태그 (여러 자리의 요구를 합집합으로 관리)
q.Tag("PlayerBadge", "Interactable"),
-- [2] 어트리뷰트 그룹 — 값은 리터럴이어도 State여도 된다
q.Attr {
CharacterName = props.Name,
Level = props.Level,
Guild = "Alpha",
},
q.Elem "TextLabel" {
Text = props.Name,
},
}
end
  1. Tag는 합집합이다: 같은 태그를 여러 자리(여러 컴포넌트, 숫자 키 자리의 Tag 값, State로 바뀌는 Tag 값 …)에서 요구해도, quad는 참조 카운트로 한 곳이라도 요구하는 동안 태그가 유지되도록 관리합니다. 마지막 요구가 사라질 때 비로소 태그가 제거됩니다. 여러 곳에서 온 Tag 값을 미리 하나로 합쳐 넘기고 싶다면 q.Tag.Merged(tagA, tagB, …)가 손실 없는 합집합을 돌려줍니다 — q.Tag(tagA, tagB, …)와 결과는 같고, Tag 값만 받는 엄격한 철자라 문자열이나 리스트를 섞으면 Quad0207 Tag.Merged: arguments must be Tag values입니다.
  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: q.State<boolean> = count:Compute(function(c: q.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)

Getting Started의 예제들은 Roblox 기본 모드(--!nonstrict)를 가정합니다. --!strict로 쓸 때 손이 더 가는 자리는 둘입니다 — 콜백 파라미터(위 예제의 q.StateData<number>처럼 :Compute/:Observer의 파라미터에 주석)와 파생 노드를 만드는 줄의 결과 타입(local isEven: q.State<boolean> = ...). 이 둘은 진단을 없애기보다 무검사 구멍을 막기 위한 것입니다(주석 없는 :Compute 결과는 어떤 타입이든 받아 Text에 숫자를 넣어도 진단이 없습니다). 진단을 실제로 없애는 데 자주 필요한 것은 따로 있습니다 — props 테이블 타입(§4), q.Slot<<Instance>>와 :List 람다의 ctx 타입·반환 팩·keyFn 파라미터 타입(08 §7), Store/Settings 열쇠 타입, ref:Unwrap()([2026-09-27 실측]). 자세한 캐비엇은 레퍼런스: State에 있습니다. --!strict에서 실제로 뜨는 진단과 그 처방을 한 표로 모은 것은 08. quad v1에서 v2로 옮기기 §7에 있습니다 — 표지는 이관 문서지만 항목 대부분은 v1 고유가 아니라 quad 일반 진단입니다.

값이든 State든 받는 prop — 본문에서 State 하나로

섹션 제목: “값이든 State든 받는 prop — 본문에서 State 하나로”

Disabled = true로도, Disabled = someState로도 받고 싶은 prop이 많습니다. 받는 자리는 T | StateMarker<T>로 두고, 본문 첫머리에서 State 하나로 정규화해 두면 그 뒤 코드는 갈래 없이 :Compute·:Get만 씁니다. 리터럴은 새 Source로 감싸고, State는 그대로 씁니다.

type ToggleProps = {
read Disabled: (boolean | QuadTypes.StateMarker<boolean>)?,
read Width: (number | QuadTypes.StateMarker<number>)?,
}
-- 타입마다 하나씩 — 리터럴인지는 `type(v)`로 가른다
local function boolState(v: (boolean | QuadTypes.StateMarker<boolean>)?, default: boolean): q.State<boolean>
if v == nil then
return q.Source(default)
elseif type(v) == "boolean" then
return q.Source(v)
end
return v :: any -- 입력 마커를 메소드가 있는 State로(아래 제네릭 레시피와 같은 캐스트)
end
local function numberState(v: (number | QuadTypes.StateMarker<number>)?, default: number): q.State<number>
if v == nil then
return q.Source(default)
elseif type(v) == "number" then
return q.Source(v)
end
return v :: any
end
local function Toggle(props: ToggleProps)
local disabled = boolState(props.Disabled, false)
local width = numberState(props.Width, 120)
-- 이 아래는 갈래 없이 disabled:Compute(...), width:Get() ...
end

헬퍼를 T 하나로 묶은 제네릭 판(asState<T>(v: T | StateMarker<T>))은 만들지 마세요. State를 넘기는 호출에서 신 솔버가 그 State 자체를 T로 잡아(T = State<number>), 결과를 q.State<number>로 받는 줄이 타입 에러가 납니다 — 바로 아래 절의 제네릭 추론 문제와 같은 뿌리입니다([2026-10-20 실측, luau-lsp 1.70.1]). 리터럴을 가르는 type(v) 검사도 타입마다 다릅니다. 그래서 쓰는 타입마다 하나씩 두는 것이 정본입니다(쓰는 타입은 몇 안 됩니다 — 불린·숫자·문자열 정도).

제네릭 컴포넌트의 타입 — 라이브러리 저자를 위한 레시피(--!strict)

섹션 제목: “제네릭 컴포넌트의 타입 — 라이브러리 저자를 위한 레시피(--!strict)”

값의 타입을 제네릭으로 받는 컴포넌트(RadioButton<T>, Combo<T>, ValueLabel<T>)를 만들면, props를 q.State<T>나 q.Source<T>로 받는 순간 신 솔버가 T를 추론하지 못하고 *“No valid instantiation could be inferred for generic type parameter T … at least: number | nil, at most: number”*로 죽습니다. q.State<EnumItem> 자리에 q.Source(Enum.EasingStyle.Quad)가 안 들어가는 것도 같은 뿌리입니다. quad 탓이라기보다 Luau 타입 검사기의 한계(교집합 타입 안에서 T가 쓰기·인자 자리에 설 때)이고, quad가 자기 입력 자리에 쓰는 방법을 그대로 쓰면 오늘 풀립니다 — State props는 q.StateMarker<T>로 받고, 메소드가 필요하면 본문에서 캐스트합니다. 설정 모듈(시작하기 01)의 타입 재수출에 한 줄을 더하세요 — export type StateMarker<T> = QuadTypes.StateMarker<T>.

-- 읽기만 하는 자리: 마커로 받고, 메소드가 필요하면 본문에서 State로 캐스트
type ValueLabelProps<T> = {
Value: q.StateMarker<T>,
Format: (T) -> string,
}
local function ValueLabel<T>(props: ValueLabelProps<T>): TextLabel
local value = (props.Value :: any) :: q.State<T>
local text: q.State<string> = value:Compute(function(v: q.StateData<T>): string
return props.Format(v:Get())
end)
return q.Elem "TextLabel" { Text = text }
end
-- 쓰는 자리: 역시 마커로 받고 본문에서 Source로 캐스트 — 입구에서 쓸 수 있는 값인지 확인
type RadioButtonProps<T> = {
Value: q.StateMarker<T>,
Option: T,
Text: string,
}
local function RadioButton<T>(props: RadioButtonProps<T>): TextButton
if not q.isSource(props.Value) then
error("RadioButton: Value must be a Source - a derived State cannot be written", 2)
end
local value = (props.Value :: any) :: q.Source<T>
return q.Elem "TextButton" {
Text = props.Text,
Activated = function()
value:Set(props.Option)
end,
}
end
local style = q.Source(Enum.EasingStyle.Quad)
local label = ValueLabel { Value = style, Format = function(s: Enum.EasingStyle) return s.Name end }
local linear = RadioButton { Value = style, Option = Enum.EasingStyle.Linear, Text = "Linear" } -- T = Enum.EasingStyle
-- RadioButton { Value = style, Option = 1, Text = "x" } -- 타입 에러: Option의 타입이 Value와 다르다

마커는 quad의 입력 자리(Text = props.Value)에 그대로 넘어가고, 넓은 자리에도 들어갑니다(q.StateMarker<EnumItem>에 q.Source(Enum.EasingStyle.Quad)). 대가가 하나 있습니다 — 쓰는 자리를 마커로 받으면 :Compute 결과 같은 파생 State가 들어와도 타입이 막지 못합니다. 그래서 위처럼 입구에서 q.isSource로 확인하거나, 적어도 문서에 “Source를 넘기라”고 적어 두세요.

같은 자리에서 자주 만나는 셋도 함께 적어 둡니다.

  • props 자리에 인라인으로 쓴 :Compute — q.Elem "TextLabel" { Text = n:Compute(function(self) … end) }처럼 다른 호출에 넘기는 테이블 안에 콜백을 바로 쓰면 콜백 매개변수의 타입이 정해지지 않아 에러가 납니다. function(self: q.StateData<number>)처럼 매개변수에 주석을 달거나, 결과를 주석 단 지역 변수로 먼저 빼세요.
  • 자식 배열 안에 인라인으로 쓴 콜백 — q.Elem "Frame" { q.PreRef(nil :: Frame?):Callback(function(inst) … end) }나 count:Observer(function(t) … end)도 같은 이유로 매개변수 주석이 필요합니다 — function(inst: Frame?), function(t: q.StateData<number>). 같은 콜백을 독립 문장으로 쓰면 주석 없이도 됩니다.
  • Slot:List에 함수 값으로 넘기는 key 함수(제네릭 함수 안이든 아니든) — 매개변수 타입을 (T, number) -> any로 적으세요. 인자를 하나만 받는 함수 값((T) -> string)은 인자를 둘 받는 함수 자리에 들어가지 않습니다(그 자리에 바로 쓴 람다는 괜찮습니다).

  • 컴포넌트는 1회 실행되는 셋업 함수인가?
  • 외부에서 주입받는 Modifier/Spread/Ref를 숫자 키 자리에 놓고 or None으로 nil-hole을 막았는가?
  • 문자 키 > 선행 Spread > 후행 Spread > 후행 Modifier > 선행 Modifier 우선순위를 알고 설계했는가?
  • Attr 값을 지울 때 None(또는 State의 nil)을 쓰고, 그룹 교체만으로는 값이 안 지워진다는 걸 아는가?
  • 반응형 로직을 담은 헬퍼 함수를 React Hook의 제약 없이 자유롭게 분리했는가?
  • 던질 수 있는 컴포넌트를 q.Fallback으로 감싸고 그 에러를 받는 자리를 정했는가? → 09. 떠 있는 것 열고 닫기 — 모달과 토스트 §5

스타일 우선순위

같은 프로퍼티를 문자 키와 Modifier 여럿이 모두 갖고 있을 때의 규칙으로 옳은 것은 무엇인가요?

  • 직접 적은 문자 키가 무조건 이기고, 숫자 키 자리에서는 뒤에 온 Modifier가 앞의 것을 필드 단위로 덮습니다
  • 숫자 키 자리에서는 앞에 온 Modifier가 이기고, 문자 키는 그 뒤에 덮입니다
  • Modifier.Overridden(A, B)에서는 방향이 반대라 앞 인자 A가 이깁니다

평탄화가 이미 채워진 키를 건너뛰기 때문에 문자 키는 어떤 Modifier도 덮지 못합니다. 숫자 키 자리와 Overridden 둘 다 뒤에 온 것이 이기는 같은 방향입니다.

어트리뷰트를 지우는 법

인스턴스에 이미 찍힌 어트리뷰트 값을 지우려면 어떻게 해야 하나요?

  • q.Attr { MyKey = None }으로 명시합니다(바인딩된 State의 값이 nil이 된 경우도 삭제입니다)
  • 그룹을 새 그룹으로 갈아 끼우면서 그 이름을 빼면 지워집니다
  • 그룹 테이블에 MyKey = nil을 적으면 지워집니다

그룹 자체가 교체되어 이름이 사라진 경우에는 그 이름의 구독만 끊기고 값은 그대로 남습니다. 그룹 테이블에 nil을 직접 적는 것은 애초에 항목이 생기지 않는 것과 같아서, 지우려는 뜻이면 None을 써야 합니다.

호출자가 넣을 자식 받기

재사용 컴포넌트가 호출자의 자식을 받을 때 권장되는 방식은 무엇인가요?

  • Slot 하나로 받아 자기 숫자 키 자리에 놓습니다 — 호출자는 나중에 :Add/:Remove로 내용을 갈아 끼울 수 있습니다
  • 자식 배열을 담은 변수를 props 테이블 자리에 그대로 넘깁니다
  • 자식 배열을 받아 테이블 리터럴 중간에서 펼쳐 넣습니다

배열을 담은 변수를 props 테이블 자리에 그대로 넘기는 것은 타입이 맞지 않아 거부되고, 펼치기는 테이블 리터럴의 마지막 원소일 때만 전부 펼쳐집니다. 컴포넌트가 형제 여럿을 돌려줄 때도 같은 이유로 Slot을 씁니다.

Quiz Progress

0 / 0 questions answered (0%)

0 correct