Tag / Attr
Tag와 Attr은 props의 숫자 키 자리에 놓는 값 객체입니다. 인스턴스에 이름표를 붙이고(Tag), 이름 붙은 값을 심습니다(Attr). 둘 다 불변이고, 모든 연산이 새 값을 돌려줍니다.
Modifier와 달리 이 둘은 프로퍼티가 아니라 엔진의 별도 채널로 갑니다. quad-base는 그 채널을 직접 건드리지 않고 주입된 엔진 op 셋(addTag/removeTag/setAttr)에 넘깁니다 — 백엔드가 그걸 채웁니다. Roblox 백엔드는 각각 CollectionService와 SetAttribute로 이어집니다.
이 페이지의 심볼: q.Tag · tag:Added · tag:Removed · tag:Contains · for name in tag · tag:Apply · q.Tag.Merged · q.Attr · attr:NameMap · q.Attr.Merged · q.Attr.Overridden · q.AttrKey · q.StringAttr · q.NumberAttr · q.BooleanAttr
이 페이지의 모든 예제는 아래 프롤로그를 전제합니다.
-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)local q = require("@game/ReplicatedStorage/Client/UI/Quad")local QuadTypes = require("@game/ReplicatedStorage/roblox_packages/quad_types") -- 설정 모듈이 다시 내보내지 않는 타입(`QuadTypes.None` 등)Tag
섹션 제목: “Tag”Tag는 이름의 집합입니다. 순서가 없고 중복이 없습니다.
type TagNames = string | { read [number]: TagNames } | TagMarker
type Tag = { read __quadTag: true, Added: (self: Tag, names: TagNames) -> Tag, Removed: (self: Tag, names: TagNames) -> Tag, Contains: (self: Tag, ...string) -> boolean, Apply: <U>(self: Tag, factory: (Tag) -> U) -> U,}이름 자리에는 어디서나 셋 중 하나가 옵니다 — 문자열, 다른 Tag(그 집합 전체), 또는 그것들의 평범한 리스트(중첩 가능). 세 문을 하나가 지키므로 생성자든 :Added든 규칙이 같습니다.
Quad0200 Tag: names must be strings, Tags, or a plain {...} list of those (got {typeof(v)})빈 문자열은 태그 이름이 아닙니다 — Quad0199 Tag: names must be strings - an empty string is not a tag name.
참조 계수로 붙습니다. 한 인스턴스의 여러 자리에서 같은 이름을 얹으면 자리마다 세어지고, 마지막 자리가 물러날 때 비로소 엔진에서 떨어집니다(엔진 호출은 이름당 정확히 한 번이지만 묶이는 단위는 한 자리의 한 전이라, 한 파동이 여러 자리를 건드리면 호출은 자리 수만큼 나뉩니다 — [2026-09-27 mock 관측]). 세는 것은 quad의 자리뿐입니다 — Claim으로 입양한 트리나 :Clone() 사본에 Studio에서 이미 붙어 있던 같은 이름의 태그는 세지 않으므로, 앱의 마지막 자리가 물러나면 그 태그도 함께 떨어집니다(StyleSheet 셀렉터로 쓰던 태그라면 스타일이 빠집니다). 밖에서 붙인 태그와 앱의 자리가 같은 이름을 쓰지 않게 하세요. 같은 불변 Tag 객체를 두 자리에 놓아도 두 번으로 셉니다. 엔진 호출은 이름별로 묶여 한 번에 나갑니다.
자리의 Tag가 갈릴 때 엔진으로 나가는 것은 옛 집합과 새 집합의 차이뿐입니다 — 빠진 이름만 removeTag, 생긴 이름만 addTag, 각각 한 번에 묶여서. 두 집합이 같으면 호출이 없습니다.
q.Tag(...names)
섹션 제목: “q.Tag(...names)”시그니처
Tag: setmetatable<{ Merged: (...TagMarker?) -> Tag }, { __call: (self: any, ...TagNames?) -> Tag }>인자
| 이름 | 타입 | 설명 |
|---|---|---|
...names |
TagNames? |
문자열 / Tag / 그것들의 평범한 리스트. 개수 제한 없음. nil 인자는 “없음” — 조건부로 넣을 때 q.Tag("Card", if even then "Even" else nil) |
반환 — 새 Tag(frozen).
동작
- 인자가 없어도 됩니다 —
q.Tag()는 빈 태그 집합입니다. - 인자마다 같은 문을 지납니다.
q.Tag(otherTag, "focus", { "a", "b" })처럼 섞어 쓸 수 있습니다. - 인자 자리의
nil은 “더할 것 없음”입니다 — 인자 개수는 정확히 세어지므로 뒤 이름이 사라지지 않습니다.:Added/:Removed/Merged도 같습니다. - 리스트는 배열이어야 합니다 — 정수가 아닌 키가 섞이거나 nil 구멍이 있으면 에러입니다. 인자와 달리 리스트 안
nil은 테이블이 담지 못해 뒤가 조용히 잘리기 때문입니다.Quad0202 Tag: names must be strings, Tags, or a plain {...} list of those - a name list is an array, not a hash tableQuad0203 Tag: names must be strings, Tags, or a plain {...} list of those - a name list cannot have nil holesQuad0200 Tag: names must be strings, Tags, or a plain {...} list of those (got a table with a metatable)Quad0200 Tag: names must be strings, Tags, or a plain {...} list of those (got an AttrKey/Mapper descriptor)
- 리스트는 몇 단계든 중첩할 수 있습니다. 다만 리스트가 자기 자신을 담는 것(
t[1] = t)은 정의되지 않은 동작입니다 — 지금은 재귀가 스택을 넘쳐 quad 내부 줄의 에러로 끝납니다(관측 — 계약 아님).
예제
local interactive = q.Tag("clickable", "focusable")local button = q.Elem "TextButton" { interactive, q.Tag("primary"), Text = "확인",}관련 — 시작하기 05. 이름표와 속성, 컴포넌트 경계 규약, 확장 가능한 디스패치 엔진
tag:Added(names)
섹션 제목: “tag:Added(names)”시그니처
Added: (self: Tag, names: TagNames?) -> Tag반환 — 이름이 더해진 새 Tag. 원본은 그대로입니다 — 결과를 대입하거나 돌려주어야 합니다.
동작 — 인자 하나를 받고, 그 하나가 문자열이든 Tag든 리스트든 상관없습니다(생성자와 같은 문). 이미 있는 이름을 더하면 집합이라 변화가 없습니다. **nil이면 아무것도 더하지 않은 새 Tag**입니다 — 그래서 조건은 인자 안에 씁니다: base:Added(if even then "Even" else nil). 문장 꼴 if even then base:Added("Even") end는 돌려준 Tag를 버려 아무 일도 안 하고, 타입 검사도 잡지 못합니다.
예제
local base = q.Tag("card")local selected = base:Added("selected")local both = base:Added({ "selected", "hovered" })local maybe = base:Added(if isSelected then "selected" else nil) -- 조건은 인자 안에 — 결과는 늘 새 Tagtag:Removed(names)
섹션 제목: “tag:Removed(names)”시그니처
Removed: (self: Tag, names: TagNames?) -> Tag반환 — 이름이 빠진 새 Tag.
동작 — :Added와 똑같이 검증합니다. 없는 이름을 빼는 것과 nil은 조용한 no-op이지만, 이름이 아닌 것을 넘기면 에러입니다. 체이닝은 왼쪽부터 읽습니다 — base:Added(if a then "A" else nil):Removed(if b then "B" else nil).
tag:Contains(...names)
섹션 제목: “tag:Contains(...names)”시그니처
Contains: (self: Tag, ...string) -> boolean인자
| 이름 | 타입 | 설명 |
|---|---|---|
...names |
string |
확인할 이름들 |
반환 — 준 이름이 전부 있으면 true.
동작 — 가변 인자입니다. 이름을 하나도 안 주면 공허하게 참입니다(동적으로 언팩한 목록이 비었을 때를 위한 것). 문자열이 아닌 인자는 조용한 false가 아니라 에러입니다.
Tag:Contains: names must be strings (got {typeof(name)} at argument {i})
예제
local tag = q.Tag("card", "selected")print(tag:Contains("card")) -- trueprint(tag:Contains("card", "selected")) -- true (전부 있어야 참)print(tag:Contains("card", "missing")) -- falsefor name in tag
섹션 제목: “for name in tag”시그니처
export type Tag = setmetatable<{ …메소드… }, { __iter: TagIter }>동작 — 이름 목록은 메소드가 아니라 Luau 일반화 반복(__iter)으로 돕니다. 반복 변수는 (이름, true)이고, 새 테이블을 만들지 않습니다. 집합이라 순서 보장이 없습니다.
pairs(tag)/next(tag)는__iter를 타지 않아 이름 대신 Tag의 내부 필드를 돕니다 — Luau는__pairs를__iter로 대체했습니다. 제너릭for로 도세요.
예제
local tag = q.Tag("a", "b")for name in tag do print(name) -- 순서는 보장되지 않는다endtag:Apply(factory)
섹션 제목: “tag:Apply(factory)”시그니처
Apply: <U>(self: Tag, factory: (Tag) -> U) -> U반환 — factory(self)의 결과.
동작 — Modifier:Apply와 같은 순수 호출 슈거입니다. 계약도 추가 의미도 없습니다. 함수가 아닌 것을 넘기면 그 자리에서 던집니다.
Quad0206 Tag: Apply factory must be a function (got {typeof(factory)})
q.Tag.Merged(...tags)
섹션 제목: “q.Tag.Merged(...tags)”시그니처
Merged: (...TagMarker?) -> Tag반환 — 모든 입력의 이름을 합친 새 Tag.
동작 — 손실 없는 합집합입니다. q.Tag(tag1, tag2, …)와 결과가 같고, Tag만 받는 엄격한 철자라는 점만 다릅니다 — 문자열이나 리스트를 섞어 넘길 수 없습니다(nil 자리는 q.Tag(...)와 같이 건너뜁니다 — if … then tag else nil 인자를 그대로 넣을 수 있습니다).
Quad0207 Tag.Merged: arguments must be Tag values
예제
local a, b = q.Tag("x"), q.Tag("y")local merged = q.Tag.Merged(a, b) -- {x, y}local same = q.Tag(a, b) -- 같은 결과, 느슨한 철자Attr
섹션 제목: “Attr”Attr은 이름 → 값 맵입니다. 값은 원시 값이거나 State/Source이거나 q.None입니다. 함수와 (State·None이 아닌) 테이블은 만들 때 거부합니다(Quad0007/Quad0008) — Store를 넘길 때도 같아서, Store 필드에 직접 넣은 함수·테이블 값도 q.Attr(store) 줄에서 걸립니다. 다만 필드가 State이고 그 현재 값이 테이블(예: Store를 담은 Source)이면 만들 때는 모르고, 적용할 때 번호 없는 에러로 죽습니다 — 속성에 묶을 State에는 원시 값만 담으세요([2026-09-27 mock 실측]).
type Attr = { read __quadAttr: true, NameMap: (self: Attr) -> { [string]: any },}
type AttrConstructor = setmetatable<{ Merged: (...Attr) -> Attr, Overridden: (...Attr) -> Attr,}, { __call: (self: any, ...any) -> Attr }>삭제 규칙 — None만이 지운다
섹션 제목: “삭제 규칙 — None만이 지운다”네 가지를 구별하세요.
| 한 일 | 결과 |
|---|---|
값에 q.None을 둔다 |
엔진에서 그 속성이 삭제됩니다 |
바인딩된 State가 q.None이 된다 |
같습니다 — 삭제됩니다 |
바인딩된 State가 nil을 내놓는다 |
이것도 삭제됩니다 — 반응형 nil은 그 키의 핸들러가 받아 setAttr(inst, name, nil)로 내려갑니다 |
그 Attr 값 객체를 다른 것으로 교체한다 |
옛 속성 값이 엔진에 그대로 남습니다 |
네 번째가 중요합니다. 자리에서 물러나는 Attr은 구독을 끊고 이름 소유권만 반납할 뿐, 엔진 쪽에는 손대지 않습니다. 지우고 싶으면 명시적으로 q.None(또는 바인딩된 State의 nil)을 보내야 합니다. 리터럴 nil은 거부되는 게 아니라 조용히 빠집니다 — 평범한 테이블이 nil을 담을 수 없어 q.Attr({ A = nil, B = 1 })은 에러 없이 { B = 1 }이 되고(타입드 슈거 q.StringAttr("A", nil)만 Quad0014로 거부), 그래서 리터럴 자리에서 지우기의 유일한 표현은 q.None입니다.
q.Attr(...)
섹션 제목: “q.Attr(...)”시그니처
__call: (self: any, ...any) -> Attr인자
| 이름 | 타입 | 설명 |
|---|---|---|
... |
Store / Attr / 평범한 테이블 |
하나의 이름 맵으로 평탄화됩니다 |
반환 — 새 Attr(frozen).
동작
- **
Store**를 주면q.Attr을 부르는 순간 그 스토어가 가진 키 전부(:Of로 그때까지 생긴 이름 포함)가 이름이 되고, 값은 각Source슬롯 자체입니다 — 그 뒤:Of로 생긴 이름은 속성이 되지 않고 필드를 다른 Source로 바꿔도 따라가지 않습니다(스냅샷, [2026-09-27 실측]). 스토어를 그대로 속성으로 내보내는 관용구입니다. - **다른
Attr**을 주면 그 이름 맵이 합쳐집니다. - 평범한 테이블(메타테이블 없는)을 주면
{ [name] = value }가 그대로 항목이 됩니다. 값은 원시 값 /State/q.None입니다. - 이름이 겹치면 뒤 인자가 조용히 이깁니다(=
Attr.Overridden의 정책). 겹침을 에러로 만들고 싶으면q.Attr.Merged를 쓰세요.
에러
Quad0010 Attr: arguments must be Stores, Attr values or plain tablesQuad0001 Attr: attribute name cannot be emptyQuad0001 Attr: plain-table keys must be non-empty stringsQuad0007 Attr: attribute "{name}" cannot be a function - attribute values are raw values or StateQuad0008 Attr: attribute "{name}" cannot be a table - attribute values are raw values, None or State (got {typeof(v)})
한 인스턴스의 두 자리에 같은 그룹 값 객체를 놓으면 그 자리에서 던집니다 — Quad0016 Attr: the same group value is placed at two positions of this instance.
예제
local hp = q.Source<<number | QuadTypes.None>>(100)local stats = q.Elem "Frame" { q.Attr({ Hp = hp, Name = "hero" }), q.Elem "TextLabel" { Text = "체력" },}hp:Set(80) -- 속성 Hp가 따라 바뀐다hp:Set(q.None) -- 속성 Hp가 삭제된다관련 — 시작하기 05. 이름표와 속성, 확장 가능한 디스패치 엔진
attr:NameMap()
섹션 제목: “attr:NameMap()”시그니처
NameMap: (self: Attr) -> { [string]: any }반환 — 평탄화된 이름 맵(frozen). 값은 저장된 그대로 — Source 슬롯은 핸들 자체로, None은 None으로 나옵니다.
동작 — 읽기용입니다. 합성 결과를 검사하거나 테스트에서 확인할 때 씁니다.
예제
local attr = q.Attr({ A = 1, B = q.Source("x"), C = q.None })local map = attr:NameMap()print(map.A) -- 1print(map.C == q.None) -- true (:Get()을 부르지 않는다)q.Attr.Merged(...)
섹션 제목: “q.Attr.Merged(...)”시그니처
Merged: (...Attr) -> Attr반환 — 합쳐진 새 Attr.
동작 — Attr 값만 받고, 이름이 겹치면 에러입니다. 두 출처가 같은 속성을 주장하는 사고를 조용히 넘기지 않으려는 자리입니다.
Quad0011 Attr.Merged: arguments must be Attr valuesQuad0004 Attr.Merged: attribute name "{name}" appears more than once
q.Attr.Overridden(...)
섹션 제목: “q.Attr.Overridden(...)”시그니처
Overridden: (...Attr) -> Attr반환 — 합쳐진 새 Attr.
동작 — Attr 값만 받되, 겹치면 뒤 인자가 조용히 이깁니다. 생성자 q.Attr(...)의 정책과 같고, 입력을 Attr로만 제한한 철자입니다.
Quad0012 Attr.Overridden: arguments must be Attr values
예제
local theme = q.Attr({ Accent = "blue", Size = 1 })local override = q.Attr({ Accent = "red" })
local final = q.Attr.Overridden(theme, override) -- Accent = "red"-- q.Attr.Merged(theme, override) 였다면 겹침 에러q.AttrKey(name)
섹션 제목: “q.AttrKey(name)”시그니처
AttrKey: (name: string) -> AttrKeyObjecttype AttrKeyObject = { read Name: string }인자
| 이름 | 타입 | 설명 |
|---|---|---|
name |
string |
속성 이름(빈 문자열 불가) |
반환 — 그 이름의 키 객체.
동작 — 속성 단일 키 프리미티브입니다. props의 문자 키 자리에 놓아 씁니다 — q.Elem "Frame" { [q.AttrKey("Hp")] = 100 }.
- 값 타입을 모르는 무타입 프리미티브입니다. 값 검증은 백엔드의
setAttr몫입니다. 패밀리 슈가(StringAttr등)가 못 덮는 엔진 고유 타입(Color3,UDim2,Instance…)이 이 키의 자리입니다. - 이름별 weak 캐시를 지납니다 — 무언가가 붙들고 있는 동안
q.AttrKey("Hp") == q.AttrKey("Hp")가 성립합니다. - 값에
q.None을 두면 그 속성이 삭제됩니다. - 한 인스턴스의 같은 이름을 서로 다른 키 객체가 주장하면 그 자리에서 던집니다 —
Quad0002 AttrKey: attribute "{k.Name}" is already bound by another owner. 던진 뒤의 상태는 정의되지 않은 동작입니다: 그룹 안에서 충돌 이름보다 먼저 위임된 이름은 이미 써졌고 그 자리를 비워도 잠긴 채 남으며(다른 주인이 가져가려 하면 같은 에러), 그 이름에 걸린 State 구독은 계속 씁니다([2026-09-27 mock 관측 — 계약 아님]). 한 인스턴스의 두State<Attr>자리 사이에서 같은 이름을 넘기는 것도 emit 순서에 달린 UB입니다 — 놓는 쪽을 먼저 비우고 다음:Set에서 새 주인을 놓으세요.
에러
Quad0001 AttrKey: name must be a non-empty string
예제
local marker = q.Elem "Frame" { [q.AttrKey("SpawnColor")] = Color3.new(1, 0, 0), -- 패밀리가 못 덮는 엔진 타입}q.StringAttr(name, value)
섹션 제목: “q.StringAttr(name, value)”시그니처
StringAttr: AttrSugar<string>type AttrSugar<T> = (name: string, value: T | StateMarker<(T | None)?> | None) -> Attr인자
| 이름 | 타입 | 설명 |
|---|---|---|
name |
string |
속성 이름(빈 문자열 불가) |
value |
string / State<string>(nil·None을 내놓아도 됨 — 그때 속성 삭제) / q.None |
값. 리터럴 nil은 거부됩니다 |
반환 — 항목이 하나뿐인 Attr(즉 q.Attr({ [name] = value })).
동작 — 타입드 스칼라 슈가입니다. 자기 핸들러를 갖지 않고 그룹 경로를 그대로 씁니다. 차이는 값 검증뿐입니다 — 패밀리는 자기 타입을 알기 때문에 원시 값의 타입을 여기서 확인합니다(State와 q.None은 그대로 통과합니다).
숫자 키 자리에 놓으므로 strict 모드에서도 타입이 섭니다. AttrKey의 문자 키 형태를 대신하는 자리입니다.
에러
Quad0001 StringAttr: name must be a non-empty stringQuad0014 StringAttr: value of "{name}" must not be nil - use None to deleteQuad0015 StringAttr: value of "{name}" must be a string, State or None (got {typeof(value)})
예제
local label = q.Source("hero")local card = q.Elem "Frame" { q.StringAttr("Label", label), q.StringAttr("Kind", "unit"),}label:Set("villain") -- 속성 Label이 따라 바뀐다local blank = q.Elem "Frame" { q.StringAttr("Label", q.None) } -- 값 자리의 None: 그 속성을 지운다q.NumberAttr(name, value)
섹션 제목: “q.NumberAttr(name, value)”시그니처
NumberAttr: AttrSugar<number>동작 — q.StringAttr과 모든 것이 같고 원시 값 타입만 number입니다.
에러 — Quad0001 NumberAttr: name must be a non-empty string / Quad0014 NumberAttr: value of "{name}" must not be nil - use None to delete / Quad0015 NumberAttr: value of "{name}" must be a number, State or None (got {typeof(value)})
q.BooleanAttr(name, value)
섹션 제목: “q.BooleanAttr(name, value)”시그니처
BooleanAttr: AttrSugar<boolean>동작 — q.StringAttr과 모든 것이 같고 원시 값 타입만 boolean입니다.
에러 — Quad0001 BooleanAttr: name must be a non-empty string / Quad0014 BooleanAttr: value of "{name}" must not be nil - use None to delete / Quad0015 BooleanAttr: value of "{name}" must be a boolean, State or None (got {typeof(value)})
예제
local hp = q.Source(100)local alive = q.Elem "Frame" { q.NumberAttr("Hp", hp), q.BooleanAttr("Friendly", true),}답변함: 0 / 0 (0%)