콘텐츠로 이동

05. 디자인 토큰과 테마 전환 — `Animate`와 `Modifier.Overridden`

대상 독자: 다크/라이트 테마와 재사용 가능한 스타일 아키텍처를 만들려는 개발자 다루는 개념: 디자인 토큰, q.Context, state:Apply(factory), q.Operator.Indexed, q.Animate, Modifier.Overridden

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")
-- Modifier 타입(`Modifier<Frame>`/`ModifierInto<TextButton>` 등)은 quad_roblox 패키지 루트가 내보낸다
local RobloxModule = require("@game/ReplicatedStorage/roblox_packages/quad_roblox")

이 문서의 예제는 모두 위 준비 코드를 앞에 둔 상태를 가정합니다 — 파일 이름이 붙은 블록(Theme.luau·Styles.luau·ThemedButton.luau)도 각각 그 세 줄로 시작하고, 다른 파일을 쓰는 블록은 그 아래에 require 줄을 적었습니다.


색을 컴포넌트마다 하드코딩하는 대신, 의미 역할로 이름을 붙입니다 — Background, Surface, TextPrimary, TextMuted, Accent, Border.

quad에는 트리를 훑어 올라가는 암묵적 컨텍스트가 없습니다. 토큰을 아래로 내리는 방법은 둘입니다.

  • props로 직접 넘기기 — 층이 얕으면 이게 가장 단순합니다.
  • q.Context — 중간 층이 앱의 스토어 스키마를 몰라도 가방 하나를 그대로 전달할 수 있는 명시적 값 컨테이너입니다. 트리 탐색은 여전히 없습니다: 컴포넌트는 가방을 이름 붙은 파라미터로 받아 자기 키만 읽습니다.
-- (가방으로 내리는 모양 — 이 문서의 나머지 절은 층이 얕아 모듈을 직접 참조합니다)
local Theme = require("./Theme")
local ThemeProvider = q.Context.Provider<<typeof(Theme)>>("Theme") -- 타입 인자를 주면 :Get 결과에 타입이 붙는다
-- 앱 진입점에서 한 번(Theme은 §2에서 만드는 토큰 모듈)
local ctx = q.Context():Set(ThemeProvider, Theme)
-- 아래 어딘가에서
local theme = ctx:Get(ThemeProvider) -- 없으면 에러
local maybe = ctx:Peek(ThemeProvider) -- 없으면 nil

이 문서의 §2 이후는 모듈 직접 참조(Theme.Tokens.…) 쪽을 씁니다 — 층이 얕아 가방이 필요 없기 때문입니다. 가방으로 내리는 전체 흐름은 시작하기 15에, Context/Provider의 전체 표면은 레퍼런스: Context에 있습니다.

  • Context.Provider(name?)가 돌려주는 것은 테이블 신원 키입니다 — 모듈 간 문자열 충돌이 없습니다. 이름은 에러 메시지용 선택 인자입니다.
  • :Set은 가방 자신을 변경하고 자기를 돌려줍니다(체이닝). 값이 nil이면 에러입니다 — “없음”은 안 넣는 것으로 표현합니다.
  • :Get은 없으면 에러, :Peek은 nil입니다. 가방을 열거하는 표면은 일부러 없습니다.

-- Theme.luau
export type ColorPalette = {
Background: Color3,
Surface: Color3,
TextPrimary: Color3,
TextMuted: Color3,
Accent: Color3,
Border: Color3,
}
local LIGHT: ColorPalette = {
Background = Color3.fromRGB(248, 249, 250),
Surface = Color3.fromRGB(255, 255, 255),
TextPrimary = Color3.fromRGB(33, 37, 41),
TextMuted = Color3.fromRGB(108, 117, 125),
Accent = Color3.fromRGB(13, 110, 253),
Border = Color3.fromRGB(222, 226, 230),
}
local DARK: ColorPalette = {
Background = Color3.fromRGB(18, 18, 18),
Surface = Color3.fromRGB(30, 30, 30),
TextPrimary = Color3.fromRGB(240, 240, 240),
TextMuted = Color3.fromRGB(160, 160, 160),
Accent = Color3.fromRGB(66, 153, 225),
Border = Color3.fromRGB(45, 45, 45),
}
local isDark = q.Source(true)
-- 지금 쓸 팔레트를 고르는 것은 한 번이면 된다
local palette: q.State<ColorPalette> = isDark:Compute(function(dark)
return if dark:Get() then DARK else LIGHT
end)
-- 토큰 하나 = "팔레트에서 키 하나를 읽는 State"에 애니메이션을 얹은 것
local function token(key: string)
return palette
:Apply(q.Operator.Indexed<<Color3>>(key))
:Apply(q.Animate({
Time = 0.25,
Style = Enum.EasingStyle.Quad,
Direction = Enum.EasingDirection.Out,
}))
end
return {
IsDark = isDark,
Toggle = function()
isDark:Set(not isDark:Get())
end,
Tokens = {
Background = token("Background"),
Surface = token("Surface"),
TextPrimary = token("TextPrimary"),
TextMuted = token("TextMuted"),
Accent = token("Accent"),
Border = token("Border"),
},
}

:Apply(factory) — 팩토리가 만든 연산을 State에 얹는다

섹션 제목: “:Apply(factory) — 팩토리가 만든 연산을 State에 얹는다”

state:Apply(factory)는 “팩토리가 만들어 준 연산을 이 State에 적용해 새 State를 얻는다”는 일반형입니다. 위 token이 그 모양을 두 번 씁니다 — q.Operator.Indexed<<V>>(key)는 상류 값에서 키 하나를 읽어 주는 팩토리(:Compute 슈거)이고, 이어 붙인 q.Animate({...})도 같은 자리에 놓이는 팩토리입니다. 덕분에 토큰마다 function(p) return p.Background end를 손으로 하나씩 둘 필요가 없습니다. :Apply가 State 메소드로서 갖는 계약은 레퍼런스: State에 있습니다.

Indexed의 값 타입은 키에서 추론되지 않으니 <<Color3>>처럼 호출자가 직접 적고, 없는 키는 에러 없이 nil이 되니 키 이름은 팔레트 쪽과 맞춰 두세요. 이런 이름 붙은 콤비네이터가 열넷 있습니다 — 레퍼런스: Operator.

애니메이션은 state:Apply(q.Animate{...})로 붙인다

섹션 제목: “애니메이션은 state:Apply(q.Animate{...})로 붙인다”

q.Tween{...}은 한 번의 목표값을 서술하는 옵션 테이블이고, 그 Value는 plain 값이어야 합니다(State를 넣으면 에러입니다). 값이 바뀔 때마다 자동으로 보간되게 하려면 State에 q.Animate를 :Apply합니다.

  • 옵션 이름은 Time / Style / Direction / RepeatCount / Reverses / DelayTime / Override(“Cancel” 또는 “Finish”) / Dedup / CanAnimate이고, Info에 TweenInfo를 통째로 줄 수도 있습니다.
  • 옵션에도 State를 넣을 수 있지만 옵션 State가 바뀌었다고 다시 애니메이션하지는 않습니다 — 다음 값 변경 때 최신 옵션이 반영됩니다.
  • CanAnimate = false면 보간 없이 값이 그대로 나갑니다(모션 축소 옵션에 쓰기 좋습니다).

옵션 하나하나의 계약과 프로퍼티 자리의 3-상태 동작(첫 세팅 스냅·Dedup·Override)은 레퍼런스: Tween과 Animate에 있습니다.


3. Modifier.Overridden으로 스타일 합성

섹션 제목: “3. Modifier.Overridden으로 스타일 합성”

Modifier는 불변입니다 — 모든 setter는 바깥 테이블과 필드 테이블을 둘 다 얕게 복제해서 새 Modifier를 만들고, 만들어진 Modifier는 얼어 있습니다. 그래서 공용 기본 스타일을 컴포넌트에 넘겨도 원본이 오염되지 않습니다. setter 체인과 Peek/Apply/Overridden의 전체 표면은 레퍼런스: Modifier에 있습니다.

-- Styles.luau
local Theme = require("./Theme")
return {
Button = q.Modifier "TextButton" ()
:Size(UDim2.new(0, 120, 0, 40))
:BackgroundColor3(Theme.Tokens.Accent)
:BorderSizePixel(0),
}

초기 필드 테이블 형태도 같은 값입니다 — q.Modifier "TextButton" { Size = ... }. 다만 테이블 형태는 필드 이름·값 타입을 strict가 검사하지 않습니다(체인 형태만).

-- ThemedButton.luau
local Styles = require("./Styles")
local function ThemedButton(props: {
read Text: string?,
read OnClick: () -> (),
read Modifier: RobloxModule.ModifierInto<TextButton>?,
})
return q.Elem "TextButton" {
Styles.Button, -- 기본 스타일
props.Modifier or q.None, -- 호출자 오버라이드 — 뒤에 놓인 Modifier가 이긴다
Text = props.Text or "",
TextColor3 = Color3.fromRGB(255, 255, 255),
FontFace = Font.fromEnum(Enum.Font.GothamBold),
TextSize = 14,
Activated = function()
props.OnClick()
end,
}
end
return ThemedButton

글꼴은 FontFace로 쓰세요. 예제가 전부 FontFace = Font.fromEnum(...)인 이유는 그게 현행 API이기 때문입니다. [2026-09-09] 옛 Font = Enum.Font.GothamBold도 타입 표면에 있어 타입 검사를 통과하지만, 엔진이 Hidden으로 표시한 레거시라 필드에 -- @deprecated 주석이 붙어 있습니다 — quad v1 코드를 옮기는 동안에만 쓰고 새 코드에는 FontFace를 쓰세요.

  • Modifier.Overridden(a, b, ...)는 필드 단위로 합치고 뒤 인자가 이깁니다. 닷 형태와 콜론 형태(a:Overridden(b))가 같은 함수입니다. 결과는 두 Modifier를 숫자 키 자리에 순서대로 놓은 것(01)과 같습니다 — 합친 값을 변수 하나로 들고 다니거나 넘겨야 할 때 이쪽을 씁니다.
  • props로 받은 Modifier는 입력 자리 마커 ModifierInto<TextButton>으로 받아 숫자 키 자리에 그대로 놓습니다 — 상위 클래스 Modifier(q.Modifier "GuiObject" ())도 들어오고 형제 클래스 것은 타입이 거부합니다. 마커에는 메소드가 없으니, 컴포넌트 안에서 setter를 불러야 하면 Modifier<TextButton>으로 받으세요(그때 호출자는 상위 클래스 값을 :AsTextButton()으로 내려 넘깁니다). mod:As<<T>>()는 무검사 캐스트이니 필요할 때만 쓰세요.
  • Modifier는 숫자 키 자리에 놓습니다. 선택적이면 props.Modifier or q.None 관용구를 씁니다.

-- SettingsCard.luau
local Theme = require("./Theme")
local ThemedButton = require("./ThemedButton")
local function SettingsCard()
return q.Elem "Frame" {
Size = UDim2.new(0, 360, 0, 240),
Position = UDim2.new(0.5, -180, 0.5, -120),
BackgroundColor3 = Theme.Tokens.Surface,
BorderSizePixel = 0,
UICorner = UDim.new(0, 12), -- 숏핸드: 관리 자식 UICorner를 만들어 붙인다
q.Elem "TextLabel" {
Size = UDim2.new(1, -40, 0, 30),
Position = UDim2.new(0, 20, 0, 20),
BackgroundTransparency = 1,
FontFace = Font.fromEnum(Enum.Font.GothamBold),
TextSize = 18,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = Theme.Tokens.TextPrimary,
Text = "화면 설정",
},
q.Elem "TextLabel" {
Size = UDim2.new(1, -40, 0, 20),
Position = UDim2.new(0, 20, 0, 55),
BackgroundTransparency = 1,
FontFace = Font.fromEnum(Enum.Font.Gotham),
TextSize = 13,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = Theme.Tokens.TextMuted,
Text = "테마와 표시 옵션을 바꿉니다.",
},
ThemedButton {
Text = "테마 전환",
OnClick = Theme.Toggle,
Modifier = q.Modifier "TextButton" ()
:Position(UDim2.new(0, 20, 1, -60))
:Size(UDim2.new(0, 140, 0, 38)),
},
}
end

Parent는 props가 아닙니다 — 만든 뒤 밖에서 card.Parent = screenGui 한 줄로 붙입니다.


  1. 토큰 갱신 경로가 하나입니다. isDark:Set만 바뀌면 그 토큰을 쓰는 모든 프로퍼티가 같은 경로로 갱신됩니다.
  2. 전환이 부드럽습니다. 각 토큰이 :Apply(q.Animate{...})를 거치므로 색이 튀지 않고 보간됩니다.
  3. 스타일 격리가 구조적으로 보장됩니다. Modifier가 불변이라, 커스터마이즈 한 사본을 넘겨도 공용 원본이 바뀌지 않습니다.

토큰 하나가 만들어지는 길

테마 팔레트 State에서 키 하나를 읽고 거기에 애니메이션까지 붙인 토큰을 만드는 방법으로 옳은 것은 무엇인가요?

  • 키 하나를 읽어 주는 팩토리를 :Apply하고, 그 위에 q.Animate를 다시 :Apply합니다
  • 키 읽기 팩토리는 키 이름에서 값 타입을 추론하므로 타입 인자를 적지 않아도 됩니다
  • 팔레트에 없는 키를 주면 그 자리에서 에러가 나므로 오타는 바로 드러납니다

state:Apply(factory)가 “팩토리가 만들어 준 연산을 이 State에 적용해 새 State를 얻는다”는 일반형이라 두 번 이어 붙습니다. 값 타입은 호출자가 직접 적어야 하고, 없는 키는 에러 없이 nil이 되니 키 이름은 팔레트 쪽과 맞춰 두어야 합니다.

Modifier를 합성할 때

공용 기본 스타일에 호출자 오버라이드를 얹는 것에 대한 설명으로 옳은 것은 무엇인가요?

  • 공용 스타일을 넘겨도 원본은 바뀌지 않고, 합성에서는 뒤 인자가 같은 필드를 덮습니다
  • setter가 원본을 제자리에서 고치므로 공용 스타일은 넘기기 전에 직접 복사해야 합니다
  • 합성에서는 앞 인자가 이기므로 기본 스타일을 뒤에 적어야 합니다

모든 setter가 바깥 테이블과 필드 테이블을 둘 다 얕게 복제해 새 Modifier를 만들고 그 결과는 얼어 있습니다. 그래서 커스터마이즈한 사본을 넘겨도 공용 원본이 오염되지 않습니다.

퀴즈 진행 상황

0 / 0 답변한 문제 (0%)

0 정답