Skip to content

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/Counter
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,
},
}
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,
},
}
end

Main.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 = playerGui

0205·0710장에서 card에 붙였던 태그·속성·Ref·관측·Effect·생명주기 훅·Modifier와 06장의 이름 UI 셋은 이 시점엔 아직 Counter.luau로 옮겨지지 않았습니다 — 이 절 서두에서 밝힌 대로 지금은 뼈대(카운트·라벨·버튼)만 옮겼습니다. 버튼 색도 08장의 파이프 대신 다시 리터럴이고, 13장에서 파이프로 되돌립니다.


카운터의 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. 폼 유효성 검사와 제출 버튼 제어가 다룹니다.


부르는 쪽이 카운터 안에 무언가를 더 넣고 싶다면, 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을 붙여 둡니다.


Quiz Progress

0 / 0 questions answered (0%)

0 correct