11. 자식이 들어갈 자리 — Slot
대상 독자: 10. 스타일을 값으로 들고 다니기를 끝낸 개발자 목표: 만들어 놓은 화면의 자식을 나중에 넣고 빼기
지금까지 자식은 만들 때 한 번 적어 넣었습니다. 그런데 화면을 만든 뒤에
자식을 더하거나 빼야 할 때가 있습니다. Slot은 숫자 키의 한 자리를
“여기는 나중에 바뀔 구간”으로 잡아 두는 값입니다.
이 장의 예제는 카운터와 별개입니다 — quad가 자리와 개수를 적어 두는 장부(Offset/Length)가 어떻게 움직이는지를 작은 상자 위에서 보고, 다음 장에서 카운터로 돌아갑니다.
1. 자리 하나 잡아 두기
섹션 제목: “1. 자리 하나 잡아 두기”Slot을 숫자 키 자리에 놓으면 그 자리가 통째로 Slot의 자식 구간이 됩니다.
-- 새 예시: 별도 스크립트(카운터와 무관)const items = q.Slot { q.Elem "TextLabel" { Text = "첫째" }, q.Elem "TextLabel" { Text = "둘째" },}
const box = q.Elem "Frame" { Size = UDim2.fromOffset(240, 160), BackgroundColor3 = Color3.fromRGB(35, 35, 42),
items, -- ← 이 자리가 Slot의 구간}
print(#box:GetChildren()) --> 2실행하면 상자 안에 라벨 둘이 보입니다. box의 숫자 키 자리에는 원소가 하나(items)뿐인데 자식은 둘입니다 — Slot이 자기가 든 만큼의 자리를 차지하기 때문입니다.
–!strict에서도 이대로 되나요?
Slot은 타입 인자를 명시해야 합니다. q.Slot()은 Slot<unknown>으로 추론되기 때문입니다.
const items = q.Slot<<Instance>>({ q.Elem "TextLabel" { Text = "첫째" },})01장에서 export type Slot<T> = QuadTypes.Slot<T>를 다시 내보내 두었으니, 파라미터 타입은 props: { Children: q.Slot<Instance>? }처럼 그대로 씁니다.
2. 넣고 빼기
섹션 제목: “2. 넣고 빼기”items에 손을 대면 그 결과가 그대로 화면에 반영됩니다. box를 다시 만들지도, 어떤 렌더 함수를 다시 돌리지도 않습니다.
-- … 위쪽 코드에 이어집니다items:Add(q.Elem "TextLabel" { Text = "셋째" })print(#box:GetChildren()) --> 3
items:Add(q.Elem "TextLabel" { Text = "앞에" }, 1) -- 1번 자리에 끼워 넣기print(#box:GetChildren()) --> 4
items:Remove(1) -- 그 자리 원소를 떼고 파괴한다print(#box:GetChildren()) --> 3
items:Clear() -- 전부 파괴하고 비운다print(#box:GetChildren()) --> 0실행하면 라벨이 그때그때 늘고 줄어듭니다.
:Remove하면 그 라벨은 어디로 가나요?
사라집니다. Slot은 자리만 빌려주는 것이 아니라 자기가 든 원소를 소유합니다 — 그래서 Slot이 원소를 버릴 때는 그 원소를 파괴합니다. 위의 :Remove도, 전부 비우는 :Clear도, 같은 자리를 새 원소로 갈아 끼우는 :Replace도 옛 원소를 파괴합니다.
살려서 꺼내는 짝이 따로 있습니다(위 코드는 :Clear()로 끝나 items가 비어 있으니, 이 둘은 라벨을 다시 넣은 뒤에 쳐 보세요 — 빈 채로 치면 Quad0236 Slot:Extract: index out of range (got 1, limit 0)가 납니다).
const taken = items:Extract(1) -- 그 자리에서 살린 채로 꺼낸다const rest = items:ExtractAll() -- 전부 살린 채로 비운다(배열로 받는다)이렇게 꺼낸 원소는 소유권이 풀린 상태라, 다른 Slot에 다시 넣거나 q.dispose로 직접 지울 수 있습니다.
자세한 것은 레퍼런스: Slot에 있습니다.
같은 원소를 두 자리에 넣으면요?
하나의 원소는 동시에 한 자리에만 마운트될 수 있습니다. 이미 다른 Slot에 든 것을 그대로 :Add 하면 거부되니, 먼저 :Extract로 꺼내세요.
3. Offset과 Length — 내 구간이 어디서 시작하나
섹션 제목: “3. Offset과 Length — 내 구간이 어디서 시작하나”Slot은 자기 상태를 두 개의 Source로 들고 있습니다.
slot.Length— 이 Slot이 차지하는 물리 자식의 개수slot.Offset— 이 Slot의 첫 원소 앞에 앞선 숫자 키 자리들의 길이 합(형제 Slot·정적 자식이 차지한 자리 수)를 나타냅니다. 0부터 세므로 앞에 아무것도 없으면 0이고, 이 Slot의 첫 원소는 전체에서Offset + 1번째가 됩니다.Offset을 구독하는 콜백(:Compute·:Observer·Effect)은 던지면 안 됩니다 — 부모의 자리 계산 도중에 불리는 값이라, 거기서 던지면 그 부모의 자리 계산이 멈춥니다(레퍼런스의Offset캐비엇).
한 부모에 Slot을 둘 놓고 직접 찍어 보면 관계가 보입니다.
-- 새 예시: 별도 스크립트const slotA = q.Slot { q.Elem "TextLabel" { Text = "A1" }, q.Elem "TextLabel" { Text = "A2" } }const slotB = q.Slot { q.Elem "TextLabel" { Text = "B1" } }
const panel = q.Elem "Frame" { q.Elem "TextLabel" { Text = "머리" }, -- 고정 자식 하나 slotA, slotB,}
print(slotA.Offset:Get(), slotA.Length:Get()) --> 1 2print(slotB.Offset:Get(), slotB.Length:Get()) --> 3 1slotA 앞에는 고정 자식 하나가 있으니 Offset이 1이고, slotB 앞에는 그 하나에 slotA의 둘이 더해져 3입니다.
앞이 자라면 뒤가 따라 밀립니다.
-- … 위쪽 코드에 이어집니다slotA:Add(q.Elem "TextLabel" { Text = "A3" })
print(slotA.Offset:Get(), slotA.Length:Get()) --> 1 3print(slotB.Offset:Get(), slotB.Length:Get()) --> 4 1실행하면 slotA는 자리가 그대로인데 길이만 늘고, slotB의 Offset이 3에서 4로 따라 움직입니다.
Length는 마운트되기 전에는 0입니다. 채워지는 것은 장부 재계산이고, 그건 Slot이 실제로 어딘가에 놓인 뒤에 돌기 때문입니다.
4. Slot 안에 Slot
섹션 제목: “4. Slot 안에 Slot”Slot의 원소로 다른 Slot을 넣을 수 있습니다. 바깥 Slot의 한 자리를 안쪽 Slot의 원소 전부가 차지합니다. 안쪽 Slot의 Offset은 부모 인스턴스 기준입니다 — 자기 위에 놓인 원소들의 길이를 전부 더한 값입니다.
-- 새 예시: 별도 스크립트const innerA = q.Slot { q.Elem "TextLabel" { Text = "Inner1" }, q.Elem "TextLabel" { Text = "Inner2" } }const innerB = q.Slot { q.Elem "TextLabel" { Text = "Inner3" } }
const outer = q.Slot { innerA, -- 원소 둘 q.Elem "TextLabel" { Text = "Outer" }, -- 원소 하나 innerB, -- 원소 하나}
const host = q.Elem "Frame" { outer }
print(#host:GetChildren(), outer.Offset:Get(), outer.Length:Get()) --> 4 0 4print(innerA.Offset:Get(), innerA.Length:Get()) --> 0 2print(innerB.Offset:Get(), innerB.Length:Get()) --> 3 1innerB의 Offset이 3인 것은 그 위에 Inner1·Inner2(innerA의 둘)와 Outer(하나)가 있기 때문입니다. outer 자신은 host의 첫 자리라 Offset 0이고, 안쪽 넷을 전부 품어 Length 4입니다.
안쪽에 하나를 더 넣으면 위로 전파됩니다.
-- … 위쪽 코드에 이어집니다innerA:Add(q.Elem "TextLabel" { Text = "Inner4" })
print(#host:GetChildren(), outer.Length:Get()) --> 5 5print(innerB.Offset:Get(), innerB.Length:Get()) --> 4 1실행하면 innerA에 하나를 넣었을 뿐인데 outer.Length와 실제 자식 수가 같이 늘고, 그 아래 있던 innerB의 Offset도 3에서 4로 밀립니다 — 장부상 Inner4는 innerA의 구간 끝, 즉 Outer 앞에 들어갔기 때문입니다.
다만 host:GetChildren()을 찍어 보면 순서는 Inner1, Inner2, Outer, Inner3, Inner4입니다. Roblox에서 자식의 순서는 물리적으로 재배치되지 않고 장부(Offset/Length)로만 관리되며, 화면에서의 순서는 Position이나 아래 5절의 LayoutOrder가 정합니다.
장부 갱신이 위로 전파되기 때문에 Slot을 몇 겹으로 쌓아도 Offset 계산이 어긋나지 않습니다.
5. 전체에서 몇 번째인지가 필요할 때
섹션 제목: “5. 전체에서 몇 번째인지가 필요할 때”Offset과 Length는 둘 다 읽기 전용 State라 그대로 구독할 수 있습니다(값을 채우는 건 quad라 :Set은 없습니다). LayoutOrder처럼 “내가 부모 전체에서 몇 번째인가”를 요구하는 프로퍼티가 있으면, 그 값을 계산해 꽂아 두면 됩니다.
-- … 3절의 panel 예시에 이어집니다(slotA에 A3를 넣은 뒤라 slotB.Offset은 4입니다)slotB:Add(q.Elem "TextLabel" { Text = "B2", -- 이 Slot의 둘째 원소가 부모 전체에서 몇 번째인가: Offset + 2 LayoutOrder = slotB.Offset:Compute(function(o: q.StateData<number>) return o:Get() + 2 end), -- 지금은 6 (테이블 안 인라인 Compute는 파라미터 타입을 적어야 strict가 봅니다)})실행하면 B2의 LayoutOrder가 6으로 놓이고, 앞의 Slot이 늘고 줄 때마다 Offset이 움직이므로 LayoutOrder도 따라갑니다.
여기서 + 2는 그 원소가 Slot 안에서 몇 번째인가입니다. 손으로 :Add 할 때는 넣는 쪽이 그 순번을 알고 있고, :List에서는 updateFn이 받는 ctx.Index로 넘겨줍니다(14장).
6. 하나만 갈아 끼우기 — State를 자리에 놓기
섹션 제목: “6. 하나만 갈아 끼우기 — State를 자리에 놓기”지금까지 숫자 키 자리에는 자식 인스턴스와 Slot을 놓았습니다. 그런데 바뀌는 것이 자식 하나뿐이라면 — 지금 보여줄 화면 하나, 지금 뜬 툴팁 하나 — Slot을 만들고 CRUD를 부르는 것도 번거롭습니다. 그럴 때는 그 자리에 State 하나를 놓으면 됩니다.
-- 새 예시: 별도 스크립트const cur = q.Source<<Instance?>>(nil)
const host = q.Elem "Frame" { q.Elem "TextLabel" { Text = "머리" }, -- 고정 자식 하나 cur, -- ← 이 자리는 cur가 가리키는 것 하나}
print(#host:GetChildren()) --> 1 (아직 비어 있다)
const a = q.Elem "TextLabel" { Text = "A" }cur:Set(a)print(#host:GetChildren(), host:GetChildren()[2].Text) --> 2 "A"
const b = q.Elem "TextLabel" { Text = "B" }cur:Set(b)print(#host:GetChildren(), host:GetChildren()[2].Text) --> 2 "B"
cur:Set(nil)print(#host:GetChildren()) --> 1 (다시 비었다)실행하면 그 자리가 cur를 따라 하나씩 갈아 끼워집니다. Slot을 만들지도, :Add/:Remove를 부르지도 않았습니다.
한 가지가 앞 절들과 다릅니다. 갈아 끼운 옛 원소는 파괴되지 않습니다 — 트리에서 떼어지기만 하고(a.Parent가 nil이 됩니다) 값 자체는 살아 있습니다. 실제로 cur:Set(a)를 다시 하면 그 인스턴스가 그대로 돌아옵니다. 그러니 떼어 낸 옛 것은 여러분 소유입니다 — 더 쓸 일이 없으면 q.dispose(a)로 파괴하세요. quad가 관리하는 값을 버리는 정본 경로가 q.dispose이고, Slot이 들고 있는 값이면 파괴 대신 에러로 알려 줍니다(inst:Destroy()는 그 확인 없이 지웁니다). 이 절처럼 State 자리에 지금 놓여 있는 원소도 같은 에러(Quad0179)로 막히고 원소는 붙은 채 남으니, 버리기 전에 먼저 다른 값으로 갈아 끼우세요(갈아 끼워 떼어진 옛 원소는 이미 내려와 있어 바로 버릴 수 있습니다).
갈아 끼우면서 옛 것은 더 쓰지 않을 거라면 두 줄을 한 줄로 줄일 수 있습니다. cur:SetAndDispose(b)는 cur:Set(b)를 한 뒤, 떼어진 옛 원소(a)를 q.dispose로 파괴합니다 — 순서가 “갈아 끼우기 → 버리기”라 위 에러에 걸리지 않고, 처음 채울 때처럼 옛 값이 nil이면 지울 것이 없어 그냥 넘어갑니다. cur:SetAndDispose(nil)은 자리를 비우면서 마지막 원소까지 버립니다. 자세한 규칙은 레퍼런스: source:SetAndDispose(v)에 있습니다.
이 자리도 진짜 Slot인가요?
아닙니다. 이 자리는 자식 인스턴스를 놓을 때와 같은 규칙으로 처리됩니다 — State가 한 겹 벗겨진 뒤 그 안의 인스턴스가 평범한 자식으로 놓이고, 갈아 끼울 때 옛 원소는 Parent = nil로 내려질 뿐 파괴되지 않습니다. Offset/Length 장부에는 앞 절들과 똑같이 참여하지만, 이 자리는 Offset 발행 채널을 두지 않아 그 자리의 Offset 값 자체는 쓸 수 없습니다. 왜 State 안의 것이 그대로 자식 자리에 앉는지는 20장에서 봅니다.
LayoutOrder처럼 이 자리의 Offset 값이 필요하면 14장의 :Single을 직접 걸어야 합니다.
7. 이 CRUD를 데이터가 대신해 준다면
섹션 제목: “7. 이 CRUD를 데이터가 대신해 준다면”지금까지는 :Add/:Remove를 손으로 불렀습니다. 화면에 보일 것이 “데이터 배열 하나”에서 나온다면 그 손이 매번 같은 일을 합니다 — 늘어난 항목만 만들고, 빠진 항목만 지우고, 남은 항목은 그대로 두는 일.
그걸 quad가 대신해 주는 것이 Slot의 다른 모드인 **:List**이고, 14. 목록 만들기에서 다룹니다.
그럼 이 Slot에 나중에 :List를 걸어도 되나요?
안 됩니다. Slot은 수동 CRUD(이 장)와 :List/:Single 두 모드 중 하나로만 살고 둘은 상호 배타라, q.Slot { ... }처럼 초기 원소를 주고 만든 Slot(심지어 빈 q.Slot {})에는 :List를 걸 수 없습니다. 그래서 목록용 Slot은 인자 없이 q.Slot()으로 만듭니다 — 자세한 규칙은 14장 2절에 있습니다.
이해 점검
섹션 제목: “이해 점검”Slot이 원소를 버릴 때
items:Remove(1)이나 items:Clear()로 Slot에서 빠진 원소는 어떻게 되나요?
- Slot은 자리만 빌려준 것이라 원소는 트리에서 떨어진 채 그대로 살아 있습니다
- Slot이 원소를 소유하므로 파괴됩니다 — 살려서 꺼내려면
:Extract나:ExtractAll을 씁니다 -
:Remove는 파괴하지만:Clear는 목록만 비우고 원소는 남겨 둡니다
Slot은 자리만 빌려주는 것이 아니라 자기가 든 원소를 소유하므로, :Remove도 :Clear도 :Replace도 옛 원소를 파괴합니다. 살려서 꺼내는 짝이 :Extract·:ExtractAll이고, 그렇게 꺼낸 원소는 소유권이 풀린 상태입니다.
Offset이 세는 것
slot.Offset이 나타내는 값은 무엇인가요?
- 이 Slot이 차지하는 물리 자식의 개수입니다
- 이 Slot 앞에 놓인 다른 Slot의 개수입니다
- 이 Slot의 첫 원소 앞에 있는 물리 자식의 개수입니다 — 첫 원소는 전체에서
Offset + 1번째가 됩니다
Offset은 앞에 놓인 물리 자식을 0부터 센 값입니다. 3절의 패널에서 slotB의 Offset이 3인 것도 앞의 고정 자식 하나에 slotA의 둘이 더해진 결과이고, 개수를 세는 쪽은 Offset이 아니라 Length입니다.
자리에 State 하나만 놓았을 때
숫자 키 자리에 State 하나를 놓아 자식을 갈아 끼울 때 옳은 것은 무엇인가요?
- Slot의
:Replace와 같아서 갈아 끼운 옛 원소가 파괴됩니다 - 옛 원소는 트리에서 떼어지기만 하고 파괴되지 않으므로, 더 쓸 일이 없으면
q.dispose로 직접 파괴해야 합니다 -
Offset/Length장부에 참여하지 않으므로 앞 구간이 자라도 이 자리는 영향을 받지 않습니다
이 자리는 Parent를 nil로 내려놓을 뿐이라 값 자체는 살아 있고, 같은 값을 다시 :Set하면 그 인스턴스가 그대로 돌아옵니다. 장부에는 앞 절들과 똑같이 참여하되, Offset 발행 채널이 없어 그 자리의 Offset 값만 쓸 수 없습니다.
더 알고 싶다면
섹션 제목: “더 알고 싶다면”- 레퍼런스:
Slot— CRUD 열하나(:Splice·:Move·:Swap·:IndexOf등), 죽은 Slot과 마운트 규칙,q.dispose - Quadnomicon Vol. 2 —
Offset/Length장부가 어떤 부분합 트리 위에서 도는지
퀴즈 진행 상황
0 / 0 답변한 문제 (0%)
0 정답
퀴즈 완료!
0%
0 / 0 정답
답변함: 0 / 0 (0%)