12. 컴포넌트로 쪼개기
This content is not available in your language yet.
대상 독자: 11. 자식이 들어갈 자리를 끝낸 개발자 목표: 카운터를 모듈 하나로 묶어 두 개를 나란히 띄우고, 상태와 자식을 바깥에서 받을 수 있게 만들기
지금 카운터는 진입점 한가운데에 통째로 적혀 있어서, 하나 더 필요해지는 순간 그 덩어리를 복사하는 수밖에 없습니다. 그래서 함수로 묶습니다.
1. 컴포넌트는 평범한 함수입니다
섹션 제목: “1. 컴포넌트는 평범한 함수입니다”Quad에서 컴포넌트는 특수한 클래스도 매크로도 아닙니다. props 테이블을 받아 만들어진 Instance를 돌려주는 평범한 Luau 함수입니다.
src/client/UI/Counter.luau를 새로 만들고, 지금까지의 코드를 그리로 옮깁니다. 바뀌어야 할 값은 props에서 받습니다.
옮겨 가는 것은 이 장에 필요한 뼈대(카운트·라벨·버튼)뿐입니다. 06장의 이름 라벨·입력창·되돌리기 버튼(nameLabel/nameBox/resetButton)과 q.Out, 05·07~10장에서 붙였던 태그·속성·07장의 buttonRef와 그 위의 :Callback/:Wait·버튼의 BackgroundTransparency 손쓰기·CardStyle Modifier·08장의 관측(:Observer)과 Effect 둘(뒤처리 이펙트·buttonRef의 게임패드 선택)·생명주기 훅은 여기서 뺐고, 08장의 버튼 색 파이프도 잠시 리터럴로 돌아갑니다(13장에서 파이프로 되돌립니다). 그것들을 컴포넌트 안쪽에 두는 모양은 01. 컴포넌트 경계 규약과 스타일 합성이 다룹니다.
-- 새 파일: ReplicatedStorage/Client/UI/Counterconst q = require("./Quad") -- 같은 폴더의 설정 모듈
return function(props) -- 상태는 컴포넌트 안에서 만든다 — 부른 쪽마다 자기 것을 갖는다 const count = q.Source(props.Start or 0)
const countText: q.State<string> = count:Compute(function(c) return `{props.Label}: {c:Get()}` end)
return q.Elem "Frame" { Size = UDim2.fromOffset(200, 120), BackgroundColor3 = Color3.fromRGB(35, 35, 42), UICorner = 12,
q.Elem "TextLabel" { Position = UDim2.fromOffset(12, 0), Size = UDim2.new(1, -24, 0, 48), BackgroundTransparency = 1, TextColor3 = Color3.fromRGB(255, 255, 255), TextScaled = true, Text = countText, },
q.Elem "TextButton" { Size = UDim2.new(1, -24, 0, 40), Position = UDim2.new(0, 12, 1, -52), BackgroundColor3 = Color3.fromRGB(0, 162, 255), TextColor3 = Color3.fromRGB(255, 255, 255), Text = "+ 1", UICorner = 8,
Activated = function() count:Set(count:Get() + 1) end, }, }end이제 진입점에서 둘을 나란히 놓습니다. 02~10장에서 만들었던 card와 card.Parent = screen 줄은 지웁니다 — 그 코드는 이제 Counter 안으로 들어갔습니다.
-- (Main.client.luau 계속 — 이제 진입점은 부르기만 합니다)const Counter = require("@game/ReplicatedStorage/Client/UI/Counter")
const row = q.Elem "Frame" { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(420, 120), BackgroundTransparency = 1,
q.Elem "UIListLayout" { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 12) },
Counter { Label = "왼쪽", Start = 0 }, Counter { Label = "오른쪽", Start = 10 },}row.Parent = screen실행하면 카운터 두 개가 나란히 뜨고, 각자 따로 셉니다. Counter를 부를 때마다 그 안의 q.Source(...)가 새로 만들어지기 때문입니다 — 컴포넌트는 한 번 실행되는 셋업 함수이고, 상태는 그 실행에 속합니다.
Counter { ... }는 Counter({ ... })의 Lua 슈거입니다 — q.Elem "Frame" { ... }과 같은 모양이라 부르는 쪽에서 구분되지 않습니다.
철학: 마법은 없다 (No Magic) 컴포넌트가 뒤에서 몰래 전역 상태를 만들거나 부모의 라이프사이클을 가로채지 않습니다. 필요한 것은 전부
props로 명시적으로 들어옵니다 — 트리를 거슬러 올라가 값을 찾아 주는 장치도 없습니다(15. 층을 건너 값 넘기기).
지금까지의 코드
섹션 제목: “지금까지의 코드”여기까지 옮기고 나면 파일이 둘로 나뉩니다 — Counter.luau가 카운터 자신을 갖고, Main.client.luau는 그걸 부르기만 합니다.
Counter.luau
-- (Counter.luau — 방금 옮긴 전체 모습)const q = require("./Quad") -- 같은 폴더의 설정 모듈
return function(props) -- 상태는 컴포넌트 안에서 만든다 — 부른 쪽마다 자기 것을 갖는다 const count = q.Source(props.Start or 0)
const countText: q.State<string> = count:Compute(function(c) return `{props.Label}: {c:Get()}` end)
return q.Elem "Frame" { Size = UDim2.fromOffset(200, 120), BackgroundColor3 = Color3.fromRGB(35, 35, 42), UICorner = 12,
q.Elem "TextLabel" { Position = UDim2.fromOffset(12, 0), Size = UDim2.new(1, -24, 0, 48), BackgroundTransparency = 1, TextColor3 = Color3.fromRGB(255, 255, 255), TextScaled = true, Text = countText, },
q.Elem "TextButton" { Size = UDim2.new(1, -24, 0, 40), Position = UDim2.new(0, 12, 1, -52), BackgroundColor3 = Color3.fromRGB(0, 162, 255), TextColor3 = Color3.fromRGB(255, 255, 255), Text = "+ 1", UICorner = 8,
Activated = function() count:Set(count:Get() + 1) end, }, }endMain.client.luau
-- (Main.client.luau — 01장부터 지금까지 전체 모습. Players·game.Loaded:Wait()·q·playerGui·screen은 01장 그대로이고, 06장의 이름 UI 셋은 서두에서 밝힌 대로 뺐습니다)const Players = game:GetService("Players")
if not game:IsLoaded() then game.Loaded:Wait() end
const q = require("@game/ReplicatedStorage/Client/UI/Quad")
const playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")
const screen = q.Elem "ScreenGui" { ResetOnSpawn = false } -- 리스폰 때 엔진이 이 화면을 지우지 않게
const Counter = require("@game/ReplicatedStorage/Client/UI/Counter")
const row = q.Elem "Frame" { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(420, 120), BackgroundTransparency = 1,
q.Elem "UIListLayout" { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 12) },
Counter { Label = "왼쪽", Start = 0 }, Counter { Label = "오른쪽", Start = 10 },}row.Parent = screen
screen.Parent = playerGui0205·0710장에서 card에 붙였던 태그·속성·Ref·관측·Effect·생명주기 훅·Modifier와 06장의 이름 UI 셋은 이 시점엔 아직 Counter.luau로 옮겨지지 않았습니다 — 이 절 서두에서 밝힌 대로 지금은 뼈대(카운트·라벨·버튼)만 옮겼습니다. 버튼 색도 08장의 파이프 대신 다시 리터럴이고, 13장에서 파이프로 되돌립니다.
2. 상태를 밖에서 받기
섹션 제목: “2. 상태를 밖에서 받기”카운터의 count는 카운터 안에서만 쓰이니 안에서 만드는 것이 맞습니다. 그런데 부르는 쪽이 그 값을 알아야 하는 컴포넌트도 있습니다 — 체크박스가 대표입니다. 체크가 켜졌는지를 정작 필요로 하는 것은 체크박스 자신이 아니라 그것을 화면에 놓은 쪽이니까요.
그럴 때는 방향을 뒤집습니다. 부르는 쪽이 q.Source를 만들어 props로 넘기고, 컴포넌트는 그것을 읽어 그린 뒤 눌리면 :Set으로 되돌려 씁니다. 상태를 만들지 않는다는 것만 다르고 나머지는 지금까지와 같습니다.
-- 새 예시: 별도 스크립트(ReplicatedStorage/Client/UI/Checkbox)const q = require("./Quad") -- 같은 폴더의 설정 모듈
return function(props) -- 상태를 만들지 않는다 — 받은 원천을 읽어 그리고, 눌리면 되돌려 쓴다 const checked = props.Value
const labelText: q.State<string> = checked:Compute(function(c) return `{if c:Get() then "☑" else "☐"} {props.Label}` end)
return q.Elem "TextButton" { Size = UDim2.new(1, 0, 0, 32), BackgroundTransparency = 1, TextXAlignment = Enum.TextXAlignment.Left, TextColor3 = Color3.fromRGB(255, 255, 255), Text = labelText,
Activated = function() checked:Set(not checked:Get()) end, }end부르는 쪽은 원천을 만들어 넘기고, 같은 원천에 파이프를 하나 더 붙여 자기 화면에 씁니다.
-- (Main.client.luau 계속 — 01장의 screen에 붙입니다)const Checkbox = require("@game/ReplicatedStorage/Client/UI/Checkbox")
const agreed = q.Source(false)
const statusText: q.State<string> = agreed:Compute(function(c) return if c:Get() then "켜짐" else "꺼짐"end)
const panel = q.Elem "Frame" { Size = UDim2.fromOffset(260, 80), BackgroundColor3 = Color3.fromRGB(35, 35, 42), UICorner = 12,
q.Elem "UIListLayout" { FillDirection = Enum.FillDirection.Vertical, Padding = UDim.new(0, 4) },
Checkbox { Label = "약관에 동의합니다", Value = agreed },
q.Elem "TextLabel" { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, TextColor3 = Color3.fromRGB(180, 180, 190), Text = statusText, },}panel.Parent = screen실행하면 체크박스를 누를 때마다 체크 표시와 아래 라벨이 같이 바뀝니다 — ☐ 약관에 동의합니다/꺼짐에서 ☑ 약관에 동의합니다/켜짐으로. 누른 자리는 컴포넌트 안이고 라벨은 밖인데도 따라오는 이유는 둘이 같은 원천에 매달린 서로 다른 파이프이기 때문입니다. 03장의 원천 → 파이프 → 프로퍼티 그림 그대로이고, 그 중간에 컴포넌트 경계가 하나 놓였을 뿐입니다. 같은 agreed를 Checkbox 둘에 넘기면 그 둘이 같이 켜지고 꺼집니다 — 안에서 q.Source를 만드는 카운터가 부를 때마다 따로 세는 것과 정확히 반대입니다.
처리된 State만 넘기고 싶다면요?
넘기면 됩니다. props.Value를 :Compute로 읽어 그리는 코드는 Source를 받든 State를 받든 똑같이 돕니다 — 03장에서 봤듯 Source는 State가 가진 것을 전부 가진 채 몇 가지를 더 가질 뿐입니다.
다른 것은 되돌려 쓰는 쪽입니다. State에는 :Set이 없습니다. 읽기만 되는 파생 노드라 넘겨받은 컴포넌트가 값을 바꿀 길이 없습니다. 그래서 “이 컴포넌트는 읽기만 한다”를 넘기는 것의 종류로 말할 수 있습니다 — 읽기만 하는 뱃지에는 State를, 값을 되돌려 쓰는 체크박스에는 Source를 넘기세요.
-- 읽기 전용으로 넘기면 컴포넌트 쪽에서 :Set을 부를 수 없다const readOnly = agreed:Compute(function(c) return c:Get() end)-- readOnly:Set(true) → attempt to call missing method 'Set' of table값이 하나일 때는 이렇게 원천 하나를 넘기면 끝입니다. 값이 여럿이고 거기에 검증과 제출까지 붙는 폼은 같은 모양을 키운 것이고, 그 판은 02. 폼 유효성 검사와 제출 버튼 제어가 다룹니다.
3. 자식을 넘겨받기
섹션 제목: “3. 자식을 넘겨받기”부르는 쪽이 카운터 안에 무언가를 더 넣고 싶다면, 11장에서 만든 Slot을 props로 받으면 됩니다. Counter가 돌려주는 q.Elem "Frame"의 마지막 원소로 한 줄을 더합니다.
-- … (Counter.luau 계속) 돌려주는 q.Elem "Frame의" 마지막 원소로 -- …버튼 생략…
props.Children or q.None, }end부르는 쪽은 그 자리에 넣을 것을 q.Slot { ... }으로 싸서 넘깁니다.
-- … (Main.client.luau) 부르는 쪽에서Counter { Label = "오른쪽", Start = 10, Children = q.Slot { q.Elem "TextLabel" { Text = "덤으로 붙는 자식", TextSize = 12 }, },},실행하면 오른쪽 카운터 안에만 라벨이 하나 더 붙습니다. Children을 안 넘긴 왼쪽 카운터는 그대로입니다 — props.Children이 nil이라 q.None이 그 자리를 지키기 때문입니다.
or q.None은 생략하면 안 됩니다. 숫자 키 자리의 표현식이 nil로 평가되면 그 자리가 02장에서 본 구멍이 됩니다. 지금은 마지막 자리라 우연히 넘어가지만, 뒤에 원소를 하나라도 더 붙이는 순간 그 자리에서 에러가 납니다. 02장에서 예고했던 그 값이 여기서 처음 필요해집니다.
-- ❌ props.Children이 없으면 이 자리가 구멍이 된다(뒤에 원소가 오면 에러)q.Elem "Frame" { q.Elem "TextLabel" { … }, props.Children }
-- ✅ None이 자리를 지킨다 — 기여는 0이지만 위치는 그대로q.Elem "Frame" { q.Elem "TextLabel" { … }, props.Children or q.None }q.None은 “여기에 아무것도 없다”를 뜻하는 약속된 값입니다. 바깥에서 받은 것을 자기 숫자 키 자리에 꽂는 것이라면 Modifier든 Ref든 전부 같은 관용구를 씁니다. 조건에 따라 자식을 넣고 빼는 자리도 같은 값을 씁니다 — (showBadge and q.Elem "TextLabel" { … }) or q.None. and가 만드는 false는 그 자리에서 Quad0076 Dispatch: no handler matched key 1 (value: boolean) - …로 거부되므로 or q.None을 빠뜨리면 안 됩니다.
받은 Slot을 왜 Children = … 같은 문자 키 자리에 꽂지 못하나요?
props.Children이라는 이름부터가 이 문서가 따르는 관례이고, 언어나 엔진이 강제하는 것은 아닙니다.
넘길 수는 있습니다 — 위 코드가 그렇게 합니다. 못 하는 것은 꽂는 쪽입니다. q.Elem "Frame" { Children = props.Children }처럼 문자 키의 값 자리에 두면 디스패치가 거부합니다.
props 테이블의 두 부분이 서로 다른 것을 받기 때문입니다.
- 문자 키(
이름 = 값)에는 프로퍼티와 이벤트가 옵니다. 값 자리에는 리터럴·State·Tween·None이 올 수 있고, 어떤 이름이 프로퍼티이고 어떤 이름이 이벤트인지는 엔진 리플렉션이 판정합니다.UICorner/UIPadding/UIPaddingOffset/UIScale네 키만은 프로퍼티가 아니라 관리 자식을 만드는 숏핸드입니다. - 숫자 키(이름 없이 놓인 원소)에는 자식 인스턴스와 자리에 놓는 값들이 옵니다 —
Modifier,Spread,Ref/PreRef/PostRef,Slot,Observer/Effect,Tag/Attr,q.OnChange "Class" "Name" (…), 구멍을 메우는q.None, 그리고 위의 것들을 담은State(11장 6절). 순서가 의미를 갖습니다.
그래서 컴포넌트는 그런 값을 props의 아무 이름으로나 받되(props.Children, props.Modifier, props.Ref), 자기 안쪽에 꽂을 때는 숫자 키 자리에 or q.None을 붙여 놓습니다. 이 경계 규약 전체는 01. 컴포넌트 경계 규약과 스타일 합성 §1·§2에 있습니다.
이해 점검
섹션 제목: “이해 점검”컴포넌트를 두 번 부르면
같은 컴포넌트를 두 번 불러 카운터 둘을 나란히 놓으면 상태는 어떻게 되나요?
- 두 카운터가 같은
count를 공유하므로 한쪽을 누르면 둘 다 올라갑니다 - 부를 때마다 그 안의
q.Source(...)가 새로 만들어져 카운터마다 자기 상태를 갖습니다 - 화면이 갱신될 때마다 컴포넌트 함수가 다시 실행되어
count가 처음 값으로 돌아갑니다
컴포넌트는 한 번 실행되는 셋업 함수이고 상태는 그 실행에 속합니다. 그래서 두 카운터가 각자 따로 세고, 함수가 다시 실행되는 일이 없으니 값이 초기화되지도 않습니다.
`or q.None`을 붙이는 이유
바깥에서 받은 props.Children을 자기 숫자 키 자리에 꽂을 때 or q.None을 생략하면 안 되는 이유는 무엇인가요?
-
Children은 원래 문자 키의 값 자리에 꽂아야 하고,q.None은 그때 쓰는 기본값이기 때문입니다 -
q.None이 그 자리에 빈 컨테이너를 하나 만들어 레이아웃을 유지해 주기 때문입니다 -
nil로 평가된 숫자 키 자리는 구멍이 되어, 뒤에 원소가 더 붙으면 에러가 나기 때문입니다
q.None은 “여기에 아무것도 없다”를 뜻하는 약속된 값이라 기여는 0이지만 위치는 그대로 지킵니다. 그래서 안 넘어올 수 있는 값을 숫자 키 자리에 꽂을 때는 or q.None을 붙여 둡니다.
더 알고 싶다면
섹션 제목: “더 알고 싶다면”- 01. 컴포넌트 경계 규약과 스타일 합성 — 바깥에서
Modifier/Ref를 받는 컴포넌트의or None관용구, 우선순위(인라인 > Spread > Modifier),Tag/Attr, 재사용 로직 추출, 체크리스트 - 레퍼런스:
Slot— CRUD 열하나, 죽은 Slot과 마운트 규칙,q.dispose
Quiz Progress
0 / 0 questions answered (0%)
0 correct
Your score
0%
0 / 0 correct
Answered: 0 / 0 (0%)