콘텐츠로 이동

05. 이름표와 속성 — Tag와 Attr

대상 독자: 04. 반응하기를 끝낸 개발자 목표: 만든 인스턴스에 이름표와 값을 달고, 화면 밖의 코드가 그걸 기준으로 찾아 쓰게 하기

지금까지 숫자 키 자리에 넣은 것은 자식이었습니다. 다른 것도 올 수 있습니다 — 인스턴스 자신에 붙는 표시입니다. Roblox에는 그런 채널이 둘 있고(CollectionService의 태그와 인스턴스의 Attribute), quad에서는 둘 다 숫자 키 자리에 값으로 놓습니다.


-- (Main.client.luau 계속 — 배치 프로퍼티는 생략합니다)
const count = q.Source(0)
const countText: q.State<string> = count:Compute(function(c) return `카운트: {c:Get()}` end)
const card = q.Elem "Frame" {
Size = UDim2.fromOffset(240, 160),
BackgroundColor3 = Color3.fromRGB(35, 35, 42),
UICorner = 12,
-- 라벨과 버튼(크기·색 같은 프로퍼티는 04장 그대로라 여기선 줄였습니다)
q.Elem "TextLabel" { Text = countText },
q.Elem "TextButton" { Text = "+ 1", Activated = function() count:Set(count:Get() + 1) end },
}

card의 숫자 키 자리 맨 앞에 한 줄을 더합니다.

-- … 위쪽 코드에 이어집니다
const card = q.Elem "Frame" {
q.Tag("Card"), -- ← 숫자 키
Size = UDim2.fromOffset(240, 160),
-- …나머지 그대로…
}

실행하면 이 Frame이 CollectionService에 태그 Card로 잡힙니다.

-- (확인용 — 화면 밖 아무 스크립트에서)
const CollectionService = game:GetService("CollectionService")
print(#CollectionService:GetTagged("Card")) --> 1
quad-base가 태그를 어떻게 처리하나요?

quad-base는 태그 채널을 직접 건드리지 않습니다. 이름 집합을 계산해 백엔드가 심어 둔 addTag/removeTag에 넘기고, quad-roblox가 그것을 CollectionService로 잇습니다.

같은 이름의 Tag를 두 곳에서 붙이면요?

태그는 집합이라 두 자리의 요구가 합집합이 됩니다. 다만 quad는 자리마다 따로 세어 두었다가 마지막 자리가 물러날 때 비로소 엔진에서 뗍니다.

const inst = q.Elem "Frame" { q.Tag("Card"), q.Tag("Card", "Panel") }
-- 태그 {Card, Panel} — 엔진 호출은 addTag:Card, addTag:Panel 둘뿐(Card는 한 번만)

두 번째 자리가 빠져도(2절처럼 태그 자리를 State로 잡아 두었는데 그 파이프가 빈 q.Tag()를 내놓는 경우) 나가는 것은 removeTag:Panel 하나입니다 — Card는 첫 자리가 아직 잡고 있습니다. 그래서 컴포넌트 여럿이 같은 인스턴스에 같은 이름을 요구해도 서로를 지우지 않습니다. 같은 불변 Tag 객체를 두 자리에 놓아도 두 자리로 셉니다.


2. 이름표도 값에서 나올 수 있습니다

섹션 제목: “2. 이름표도 값에서 나올 수 있습니다”

태그 자리에 State를 놓으면 그 파이프가 내놓는 Tag가 그대로 이름표가 됩니다. 여기서 짚어 둘 것 하나 — Tag는 불변이라 :Added/:Removed는 원본을 고치는 게 아니라 새 Tag를 돌려줍니다. 그래서 조건은 if문으로 감싸는 게 아니라 인자 안에 씁니다.

const card = q.Elem "Frame" {
q.Tag("Card"),
count:Compute(function(c: q.StateData<number>) -- 숫자 키 자리의 인라인 Compute도 파라미터 타입을 적어야 strict가 봅니다
return q.Tag("Card"):Added(if c:Get() % 2 == 0 then "Even" else nil)
end),
-- …나머지 그대로…
}

nil은 “더할 것 없음”입니다 — 그래서 짝수일 땐 {Card, Even}, 홀수일 땐 {Card}가 나갑니다. if even then base:Added("Even") end처럼 문장으로 쓰면 돌려준 Tag를 그 자리에서 버려 아무 일도 하지 않고, Tag가 불변인 걸 몰랐다는 신호를 타입 검사도 잡아 주지 않습니다 — 반환값을 버리는 문장이라 문법만 보면 멀쩡합니다. 그래서 조건은 늘 인자 자리에, 결과는 =으로 받는 걸 규칙으로 삼습니다. 이름표를 하나 더 갈아 끼우고 싶으면 :Added(...):Removed(...)처럼 이어 붙입니다 — 왼쪽부터 읽히는 체인입니다.

실행하면 버튼을 누를 때마다 이름표가 갈아 끼워집니다 — {Card, Even} → {Card} → {Card, Even}. 정적으로 적은 Card는 교체 내내 남습니다(1절의 자리별 셈 그대로) — 파이프가 내놓는 Tag에도 매번 "Card"를 다시 적었지만, 그건 집합을 맞추기 위한 것일 뿐 엔진에 새로 붙는 게 아닙니다(자리 자체가 다르니까요). 엔진 호출도 진짜 바뀐 이름에만 나갑니다 — 짝수에서 홀수로 넘어가는 클릭 한 번이 만드는 것은 removeTag:Even 하나뿐입니다.

“진짜 바뀐 이름에만”을 한 번 더 짚어 둘 만합니다. 파이프가 새 Tag를 내놓을 때 quad가 하는 일은 옛 이름 집합과 새 이름 집합을 대 보는 것입니다 — 빠진 이름만 떼고, 생긴 이름만 붙입니다. 그래서 두 집합이 같으면 엔진으로 나가는 호출이 하나도 없습니다.

-- (확인용 — 카드가 막 만들어진 참이라 count는 0, 이름표는 {Card, Even}. 확인이 끝나면 아래 두 줄은 지웁니다)
count:Set(2) -- 짝수 → 짝수: 집합이 {Card, Even} 그대로라 엔진 호출 0
count:Set(3) -- 짝수 → 홀수: 집합이 {Card}가 되어 removeTag:Even 하나
바뀔 때마다 태그를 전부 떼었다 다시 붙이는 건 아닌가요?

아닙니다. 여기에 1절의 자리별 셈이 겹칩니다 — 정적으로 적은 q.Tag("Card")는 자기 자리가 물러날 때까지 그대로이므로, 교체 도중에 Card가 잠깐 떨어지는 구간이 없습니다. 태그를 보고 반응하는 바깥 코드(CollectionService:GetInstanceRemovedSignal 같은 것)가 쓸데없이 깨어나지 않는다는 뜻이기도 합니다.

숫자 키 자리를 State로 잡아 두고 갈아 끼울 수 있는 것은 Tag만이 아닙니다. State<> 안에 들어갈 수 있는 것이 생각보다 넓어서, 08장에서는 관측 핸들을 이 방법으로 껐다 켜고, 11장에서는 자식 인스턴스를 이 방법으로 갈아 끼우게 됩니다. 세 장이 각각 “이렇게도 된다”고 말하지만 사실은 원리 하나이고, 그 하나는 20장에서 봅니다.


이름표가 “무엇인가”라면 Attribute는 “얼마인가”입니다. 여러 개는 그룹 하나로, 하나씩은 타입별 슈가로 놓습니다.

-- … 위쪽 코드에 이어집니다
const active = q.Source<<boolean | typeof(q.None)>>(true) -- 뒤에서 q.None을 넣어 속성을 지울 것이라 타입에 None을 넣는다
const card = q.Elem "Frame" {
q.Tag("Card"),
-- …§2의 짝수/홀수 파이프 그대로…
q.Attr { Kind = "counter", Count = count }, -- 그룹 하나 — 값 자리에 State를 그대로 넣는다
q.BooleanAttr("Active", active), -- 값 하나(State도 된다)
-- …나머지 그대로…
}

그룹 안에 넣은 Count = count를 눈여겨보세요 — 항목 값은 원시 값뿐 아니라 State/Source도 그대로 받습니다(2절의 Tag처럼 항목 하나를 파이프로 감싸는 게 아니라, 그룹 자체는 리터럴로 두고 그 안의 한 값만 반응하게 둘 수 있다는 뜻입니다). count는 이 장 맨 위에서 만든 그 카운터 Source이므로, 여기서는 새로 만들 것이 없습니다.

실행하면 셋이 그 인스턴스의 Attribute로 심깁니다(Studio 속성 창 아래쪽 Attributes 칸). 값 타입도 그대로 보존됩니다 — 문자열은 문자열로, 숫자는 숫자로, 불리언은 불리언으로 — 그리고 Count는 count가 바뀔 때마다 따로 심을 필요 없이 따라갑니다.

-- (확인용 — 화살표 뒤는 실제 출력이 아니라 그 시점의 Attribute 상태입니다. 확인이 끝나면 active:Set 두 줄은 지웁니다)
card:GetAttributes() --> {Active = true, Kind = "counter", Count = 0}
-- (+1 버튼을 두 번 누르면) card:GetAttributes() --> {..., Count = 2} — count를 따라간다
active:Set(false) -- 이제 {Active = false, Kind = "counter", Count = 2}
active:Set(q.None) -- 이제 {Kind = "counter", Count = 2} — Active가 사라진다
Attr 값을 지우려면요?

리터럴로 적는 자리에서 지우기의 유일한 표현은 **q.None**입니다. 넷을 구별하세요.

한 일 결과
값에 q.None을 둔다 엔진에서 그 속성이 삭제됩니다
바인딩된 State가 q.None이 된다 같습니다 — 삭제됩니다
바인딩된 State가 nil을 내놓는다 이것도 삭제됩니다 — State가 흘려보내는 nil은 거부되지 않습니다
그 Attr 값 객체를 다른 것으로 교체한다 옛 속성 값이 엔진에 그대로 남습니다

네 번째가 함정입니다(Attr을 내놓는 State로 자리를 잡아 두고 그 파이프가 다른 Attr을 내놓는 경우 — 2절의 Tag와 같은 모양이고, 원리는 20장에서 봅니다). 자리에서 빠지는 Attr은 구독을 끊고 이름을 반납할 뿐 엔진 쪽 값에는 손대지 않습니다. 리터럴 nil은 거부가 아니라 평범한 테이블이 nil을 담을 수 없어 항목이 조용히 사라지는 것이고(q.Attr({ A = nil })은 빈 그룹), State 안의 nil은 그 문제가 없어 그대로 삭제로 흐릅니다.

지우기 말고 하나가 더 있습니다 — 한 인스턴스의 두 자리가 같은 이름을 요구하는 경우입니다.

이름이 겹치는 Attr을 한 인스턴스의 두 자리에 놓으면요?

이기는 쪽이 없습니다 — 그 자리에서 에러가 납니다.

q.Elem "Frame" { q.Attr { Kind = "counter" }, q.Attr { Kind = "other" } }
-- Quad0002 AttrKey: attribute "Kind" is already bound by another owner

이름 하나의 주인은 하나여야 한다는 규칙이라, 겹칠 수 있으면 먼저 합쳐서 하나로 넘깁니다. 겹침을 사고로 볼 자리에는 q.Attr.Merged(a, b)(겹치면 에러), 의도된 덮어쓰기에는 q.Attr.Overridden(a, b)(뒤 인자가 조용히 이김)를 씁니다.


4. 붙인 것으로 찾기 — QueryDescendants

섹션 제목: “4. 붙인 것으로 찾기 — QueryDescendants”

이름표와 속성이 붙었으니 화면 밖의 코드가 그걸 기준으로 인스턴스를 고를 수 있습니다. Instance:QueryDescendants(selector)는 선택자에 맞는 자손 전부를 배열로 돌려줍니다. 01장에서 만든 screen(ScreenGui)에 대고 부릅니다.

print(#screen:QueryDescendants(".Card")) --> 1 태그가 Card인 것
print(#screen:QueryDescendants("[$Kind=counter]")) --> 1 Attribute Kind가 counter인 것

실행하면 둘 다 1이 찍힙니다 — 태그로 고른 것도 속성으로 고른 것도 방금 만든 그 카드 하나입니다.

선택자 문법은 뭐가 더 있나요?
선택자 뜻 예
ClassName 클래스(IsA 기준이라 상위 클래스도 맞습니다) Frame, GuiObject
.Tag CollectionService 태그 .Card
#Name 인스턴스의 Name #Card
[$Attr] / [$Attr=값] Attribute가 있는가 / 값이 같은가 [$Kind], [$Step=1]
[속성=값] 프로퍼티 값이 같은가 [Visible=true]
A > B / A >> B 직계 자식 / 자손 .Card >> TextLabel
A, B 합집합 Frame, TextLabel
선택자가 조용히 0개를 돌려주는데요?

에러 없이 빈 배열이 오는 경우가 둘 있고, 둘 다 CSS 습관에서 옵니다.

  1. 공백은 자손이 아니라 AND입니다. .Card TextLabel은 “Card 태그가 붙은 동시에 TextLabel인 것”으로 읽혀 대개 0개가 됩니다. 자손은 >>입니다.
  2. Attribute에 $를 빠뜨리면 에러가 아니라 0개입니다. [Kind=counter]는 Kind라는 프로퍼티를 찾으므로 아무것도 안 걸립니다.

값에 공백이 있으면 큰따옴표로 감쌉니다([$Label="two words"]). 작은따옴표는 에러이고, 값 없는 존재 검사는 Attribute에만 됩니다 — [Visible]은 '=' expected after property name이 납니다.

여기서 붙인 태그는 UI 스타일시트가 고르는 기준이기도 합니다. StyleRule의 선택자에서 .Card는 지금 붙인 그 태그를 가리키므로, quad로 선언한 태그 위에 스타일시트를 얹어 바깥에서 모양을 갈아 끼우는 구성이 가능합니다. 구성법 자체는 Roblox 쪽 기능이라 아래 공식 문서로 넘깁니다.

백엔드 없이도 되나요?

값을 만들고 합성하는 것까지는 됩니다. 실제로 인스턴스에 적용하는 것만 백엔드의 몫이라, 백엔드 없이 거기까지 가면 안내 에러가 납니다 — Quad0108 quad: addTag is not available - no backend has installed the tag ops …(뒤에 프로바이더를 설치하라는 안내가 붙습니다). 01장에서 Quad:UseProvider(QuadRoblox)를 이미 했다면 신경 쓸 일이 없습니다.


같은 이름표를 두 자리에서

한 인스턴스의 두 자리가 같은 이름의 q.Tag를 요구하다가 그중 한 자리가 빠지면 어떻게 되나요?

  • 뒤에 온 자리가 앞의 것을 덮고 있었으므로, 빠지는 순간 그 이름이 엔진에서 떨어집니다
  • quad가 자리마다 따로 세어 두므로, 마지막 자리가 물러날 때에야 엔진에서 뗍니다
  • 같은 이름을 한 인스턴스의 두 자리에 놓는 것 자체가 에러라 그런 상황이 생기지 않습니다

태그는 집합이라 두 자리의 요구가 합집합이 되고, quad는 자리마다 따로 세어 두었다가 마지막 자리가 물러날 때 비로소 엔진에서 뗍니다. 그래서 컴포넌트 여럿이 같은 인스턴스에 같은 이름을 요구해도 서로를 지우지 않습니다.

`Attr` 값을 교체하면

자리에 놓은 Attr 값 객체를 다른 것으로 교체하면 엔진 쪽 속성은 어떻게 되나요?

  • 교체되는 순간 옛 속성이 삭제되고 새 값만 남습니다
  • 옛 속성 값이 엔진에 그대로 남습니다 — 빠지는 Attr은 구독을 끊고 이름을 반납할 뿐입니다
  • 두 값이 합쳐져 옛 이름과 새 이름이 모두 심긴 채로 남습니다

지우기의 표현은 q.None이고, 바인딩된 State가 q.None이나 nil을 내놓을 때도 삭제됩니다. 값 객체를 교체하는 것만은 지우기가 아닙니다 — 자리에서 빠지는 Attr은 구독을 끊고 이름을 반납할 뿐 엔진 쪽 값에는 손대지 않습니다.

선택자가 0개를 돌려줄 때

screen:QueryDescendants(".Card TextLabel")이 에러 없이 0개를 돌려주는 이유는 무엇인가요?

  • 태그 선택자 뒤에는 클래스 이름을 이어 쓸 수 없어 뒤쪽이 통째로 무시되기 때문입니다
  • 공백은 자손이 아니라 AND라, Card 태그가 붙은 동시에 TextLabel인 것을 찾기 때문입니다
  • QueryDescendants는 직계 자식만 보므로 한 단계 아래가 아닌 것은 걸리지 않기 때문입니다

공백은 AND로 읽히고 자손은 >>입니다. 같은 종류의 함정이 하나 더 있는데, Attribute 선택자에서 $를 빠뜨린 [Kind=counter]는 에러가 아니라 그 이름의 프로퍼티를 찾아 0개가 됩니다.


지금까지(03~05장) 배운 것은 전부 값을 상태에서 인스턴스로 내려보내는 흐름이었습니다. 현실의 화면에는 반대 방향도 있습니다 — 사용자가 입력창에 타이핑하는 값처럼, 인스턴스에서 상태로 올라오는 값입니다. 06장에서 그 반대 방향을 봅니다.

퀴즈 진행 상황

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

0 정답