콘텐츠로 이동

03. `Slot:List`로 긴 목록 다루기 — 재활용과 윈도잉

대상 독자: 수백~수만 개짜리 목록을 ScrollingFrame에 그려야 하는 개발자 다루는 개념: LayoutOrder/Position 바인딩, 윈도잉, userdata, Throttle 먼저: Slot:List의 기본(계약·재사용·KeyGone·Detach)은 시작하기 14. 목록 만들기에서 배웁니다. 이 문서는 그 위에서 긴 목록을 다루는 법만 봅니다. 재활용과 윈도잉은 서로 다른 행에 걸립니다: 재활용은 화면에 계속 남아 있는 키에만 적용되고, 윈도우 밖으로 나가 키가 사라진 행은 재활용이 아니라 파괴(또는 q.Detach로 홀드)입니다(§6).


ScrollingFrame 안에 항목 수만큼 Instance를 만들면 생성 비용과 레이아웃 비용이 같이 늘어납니다. 해법은 둘 — 재활용(목록이 바뀌어도 살아남은 항목은 다시 만들지 않는다)과 윈도잉(보이는 구간+여유분만 만든다)입니다.

둘 다의 토대는 Slot():List입니다. 다만 quad는 위치를 대신 정해주지 않습니다 — ctx.Index와 ctx.Offset을 넘겨줄 뿐, 그것을 LayoutOrder에 쓸지 Position에 쓸지는 전적으로 updateFn을 쓰는 사람의 몫입니다(3절).

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")

2. 긴 목록에서 특히 중요한 계약 셋

섹션 제목: “2. 긴 목록에서 특히 중요한 계약 셋”

전체 계약(ctx 필드 다섯과 네 갈래 반환)은 시작하기 14와 레퍼런스: Slot에 있습니다. 여기서는 목록이 길어질 때 비용을 가르는 셋만 짚습니다.

Slot():List(data, updateFn, keyFn?, opts?)
updateFn(ctx) -> (result, userdata)
-- ctx = { Item, Index, Offset, Prev, UserData } (호출마다 새 테이블)
  • keyFn(item, i)가 재활용을 결정합니다. 항목의 신원이 안정적이어야 ctx.Prev가 넘어오고, ctx.Prev를 그대로 돌려주는 갈래가 마운트도 파괴도 없는 가장 싼 경로입니다. 생략하면 배열 인덱스가 키인데, 그러면 목록이 밀릴 때마다 신원이 어긋나 전부 새로 만들어집니다.
  • 항목마다 바뀌는 값은 userdata에 Source로 넣어 둡니다(다음 호출의 ctx.UserData로 돌아옵니다). 재활용 갈래에서는 인스턴스를 다시 만들 수 없으니, 라벨 텍스트나 위치는 그 Source만 :Set 합니다.
  • opts.OwnsElements = false — 이 Slot이 요소를 파괴하지 않습니다(목록에서 빠질 때 언마운트만). 밖에서 만들어 넘긴 Instance를 목록에 태울 때 씁니다 — 단 그 Instance는 먼저 Claim으로 quad 소유로 넘겨져 있어야 합니다(claim되지 않은 원소는 Slot이 거부합니다). OwnsElements = false는 “버릴 때 파괴하지 않는다”는 뜻이지 소유 검사를 건너뛰는 옵션이 아닙니다. 그리고 목록이 마운트된 채로 그 화면(부모 Instance)이 파괴되면 엔진이 자손을 지우므로 넘긴 Instance도 같이 죽습니다 — 살려야 하면 철거 전에 data에서 빼세요.

data는 평범한 배열이거나 배열을 담은 State입니다. State면 값이 바뀔 때마다 재조정(reconcile)이 돕니다.


3. 순서와 위치는 호출자가 정한다

섹션 제목: “3. 순서와 위치는 호출자가 정한다”

Slot은 LayoutOrder라는 이름을 알지 못합니다. 자동으로 넣어주는 안은 검토 후 기각됐습니다 — 컴포넌트가 스스로 지정한 LayoutOrder를 Slot이 조용히 덮어쓰게 되고(“매직 없이 명시적”이라는 기조와 충돌), LayoutOrder는 Roblox 전용 프로퍼티라 엔진을 모르는 quad-base의 Slot 층에 그 지식이 새는 레이어링 위반이기 때문입니다.

UIListLayout으로 세로 배치를 할 때의 정본 관용구는 이렇습니다.

local function updateFn(ctx: {
Item: any,
Index: number,
Offset: q.State<number>, -- 이 Slot 앞에 놓인 물리 자식 수를 담은 읽기 전용 State
Prev: any,
UserData: any,
}): (any, any)
if ctx.Item == q.KeyGone then
return nil, ctx.UserData -- 사라진 키 — 파괴
end
if not ctx.Prev then
-- 새 요소: 이전 Source를 재사용하지 말고 처음부터 올바른 값으로 만든다
local layoutOrder = q.Source(ctx.Index)
local order: q.State<number> = layoutOrder:Depend(ctx.Offset):Compute(function(i)
return i:Get() + ctx.Offset:Get()
end)
return q.Elem "Frame" {
LayoutOrder = order,
-- ...
}, { layoutOrder = layoutOrder }
end
-- 재활용: 실제로 바뀔 때만 Set
local layoutOrder = ctx.UserData.layoutOrder
if layoutOrder:Get() ~= ctx.Index then
layoutOrder:Set(ctx.Index)
end
return ctx.Prev, ctx.UserData
end

⚠️ :Depend(ctx.Offset)으로 모은 값은 Compute 콜백에 포지셔널로 넘어오지 않습니다. 두 번째 자리에 오는 것은 직전 계산 결과(previous)입니다 — 위처럼 클로저로 ctx.Offset:Get()을 직접 읽으세요.

:Compute를 props 테이블 안에 인라인으로 쓰지 않고 위처럼 타입 붙인 지역 변수로 빼는 이유는 에디터 타입 검사입니다 — 함수 인자로 넘기는 테이블 리터럴 안에서는 무주석 콜백 파라미터가 풀리지 않습니다.


4. 윈도잉 — 보이는 구간만 그리기

섹션 제목: “4. 윈도잉 — 보이는 구간만 그리기”

윈도잉을 하면 목록의 일부만 자식으로 존재하므로 UIListLayout이 전체 스크롤 높이를 만들어낼 수 없습니다. 그래서 윈도우 방식에서는 LayoutOrder가 아니라 항목의 절대 인덱스로 Position을 직접 계산하고, 스크롤 범위는 CanvasSize로 고정합니다. 3절의 LayoutOrder 관용구는 이 레시피에선 쓰이지 않습니다 — 둘 중 하나를 고르세요.

local ROW = 50
local BUFFER = 3
local function VirtualList(props: { Items: { { id: string, label: string } } })
local items = props.Items
local scrollY = q.Source(0)
local viewportHeight = q.Source(600)
-- 보이는 구간을 반응형으로 잘라낸다(후행 의존성: fn(self, previous, ...deps))
local window = scrollY:Compute(function(y, _previous, height)
local first = math.floor(y:Get() / ROW) + 1
local count = math.ceil(height:Get() / ROW) + 1
local startIndex = math.max(1, first - BUFFER)
local endIndex = math.min(#items, first + count + BUFFER)
local slice = {}
for i = startIndex, endIndex do
table.insert(slice, { item = items[i], absIndex = i })
end
return slice
end, viewportHeight)
local rows = q.Slot<<Instance>>():List(window, function(ctx: any): (any, any)
local entry, prev, ud = ctx.Item, ctx.Prev, ctx.UserData
if entry == q.KeyGone then
return nil, ud
end
if prev then
-- 화면에 남아 있는 행: 절대 위치만 갱신하고 Instance는 재활용
if ud.abs:Get() ~= entry.absIndex then
ud.abs:Set(entry.absIndex)
end
return prev, ud
end
local abs = q.Source(entry.absIndex)
local position: q.State<UDim2> = abs:Compute(function(a)
return UDim2.new(0, 0, 0, (a:Get() - 1) * ROW)
end)
local row = q.Elem "Frame" {
Size = UDim2.new(1, 0, 0, ROW),
Position = position,
BackgroundTransparency = 1,
q.Elem "TextLabel" {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Text = entry.item.label,
},
}
return row, { abs = abs }
end, function(entry)
return entry.item.id
end) -- 생성자의 제네릭은 추론되지 않는다 — `q.Slot<<Instance>>()`처럼 명시 타입 인자로 준다
return q.Elem "ScrollingFrame" {
Size = UDim2.fromScale(1, 1),
CanvasSize = UDim2.new(0, 0, 0, #items * ROW),
ScrollBarThickness = 6,
q.OnChange "ScrollingFrame" "CanvasPosition" (function(pos: Vector2)
scrollY:Set(pos.Y)
end),
q.OnChange "ScrollingFrame" "AbsoluteWindowSize" (function(size: Vector2)
viewportHeight:Set(size.Y)
end),
rows,
}
end

q.OnChange "Class" "Name" (fn)는 숫자 키 자리에 놓는 디스크립터입니다(문자 키가 아닙니다). 콜백은 엔진 인자만 받습니다.


5. 스크롤 이벤트 폭주 누그러뜨리기 — Throttle

섹션 제목: “5. 스크롤 이벤트 폭주 누그러뜨리기 — Throttle”

CanvasPosition은 한 프레임에도 여러 번 바뀔 수 있고, 바뀔 때마다 §4의 window 계산과 Slot:List 재조정이 돕니다. 목록을 그렇게 자주 다시 자를 필요는 없으니, 원천에 q.Throttle을 :Apply로 붙여 정해진 창마다 한 번만 아래로 통지하게 하고, window는 그 게이트된 State를 보게 합니다.

-- §4의 VirtualList 안에서 바뀌는 두 곳
local scrollY = q.Source(0) -- OnChange 콜백은 그대로 이걸 :Set 한다
local gatedScrollY: q.State<number> = scrollY:Apply(q.Throttle { Time = 0.1 }) -- 0.1초 창마다 한 번 통지 — 결과 타입은 호출부가 명시(레퍼런스 sugar/03)
local window = gatedScrollY:Compute(function(y, _previous, height)
-- …§4 본문 그대로…
end, viewportHeight)
  • 창 안에서 막히는 것은 통지이지 값이 아닙니다 — gatedScrollY:Get()은 언제나 마지막 스크롤 위치입니다.
  • 스크롤이 멈춘 뒤에 한 번만 계산하고 싶다면 q.Debounce를 씁니다. 옵션과 Flush/Cancel은 레퍼런스: Debounce / Throttle에 있습니다.
  • 시간이 아니라 코드 구간을 직접 열고 닫고 싶다면(데이터 여러 개를 한꺼번에 바꾸는 동안 목록을 멈추는 경우) Blocker가 그 도구입니다 — 시작하기 18.

6. 확인 방법과 이 레시피가 안 해주는 것

섹션 제목: “6. 확인 방법과 이 레시피가 안 해주는 것”

확인 — 개발자 콘솔(F9) → Memory의 Instances 수가 목록 길이가 아니라 윈도우 크기에 비례해 머무는지, 그리고 updateFn의 “새로 만드는” 갈래에 카운터를 넣어 살아남은 키가 다시 만들어지지 않는지 봅니다.

안 해주는 것

  • 행 높이 측정이 없습니다. 위 예제는 모든 행이 ROW로 같은 높이라고 가정합니다 — 가변 높이는 직접 누적 높이를 관리해야 합니다.
  • 윈도우 밖으로 나간 행은 재활용되지 않습니다. 키가 사라지면 파괴(또는 q.Detach로 홀드)이고, 재활용은 “화면에 계속 남아 있는 키”에만 적용됩니다.

Roblox에서는 형제의 물리적 순서가 화면 순서를 정하지 않습니다 — 순서는 UIListLayout의 SortOrder/LayoutOrder나 ZIndex가 정합니다. 그래서 quad-roblox 백엔드의 재정렬 op(nativeMove/nativeSwap)는 의도된 no-op이고, slot:Move/slot:Swap으로 바뀌는 것은 quad의 부기(이 Slot 안의 인덱스, slot:Get/slot:IndexOf, 이후 삽입 위치)뿐입니다. 그래서 이 레시피가 순서를 LayoutOrder 바인딩으로 정합니다(§3). 계약 전체는 레퍼런스: Slot의 :Move/:Swap 절을 보세요.


무엇이 재활용을 결정하나

긴 목록에서 살아남은 항목이 다시 만들어지지 않게 하는 것은 무엇인가요?

  • keyFn이 정하는 항목의 신원입니다 — 생략하면 배열 인덱스가 키가 되어, 목록이 밀릴 때마다 신원이 어긋나 전부 새로 만들어집니다
  • keyFn을 생략해도 quad가 항목 테이블의 신원으로 같은 항목을 알아봅니다
  • keyFn은 항목의 정렬 순서를 정하는 함수라 재활용과는 무관합니다

신원이 안정적이어야 ctx.Prev가 넘어오고, ctx.Prev를 그대로 돌려주는 갈래가 마운트도 파괴도 없는 가장 싼 경로입니다.

윈도잉에서 위치를 잡는 법

보이는 구간만 그리는 윈도우 방식에서 행의 위치와 스크롤 범위는 어떻게 잡나요?

  • 항목의 절대 인덱스로 Position을 직접 계산하고, 스크롤 범위는 CanvasSize로 고정합니다
  • 전체 목록과 똑같이 LayoutOrder로 순서만 정하고 배치는 UIListLayout에 맡깁니다
  • 행 높이는 quad가 측정해 주므로 가변 높이 행도 그대로 둘 수 있습니다

목록의 일부만 자식으로 존재하니 레이아웃이 전체 스크롤 높이를 만들어낼 수 없어서, 두 방식 중 하나를 골라야 합니다. 그리고 quad는 행 높이를 측정해 주지 않으므로, 절대 인덱스로 위치를 계산하는 방식은 모든 행이 같은 높이라고 가정합니다.

퀴즈 진행 상황

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

0 정답