
얼마 전 대량의 데이터(수백개에서 수천개의 row)를 보여주는 테이블을 만들었는데, 스크롤을 이동하거나 필터 변경으로 테이블을 다시 렌더할 때 속도가 생각보다 너무 느렸다.
그래서 한 번에 모든 데이터를 가져오는 대신 무한스크롤 형식으로 변경할까 고민했는데, 스크롤을 내려 데이터를 추가로 계속 가져오다보면 결국에는 렌더하는 row의 개수가 많아져 똑같이 속도 이슈가 생길 것 같았다. 무한스크롤은 데이터를 나눠 가져오는 방식이기 때문에 네트워크 부담은 줄일 수 있지만, 이미 화면에 렌더링된 row 자체가 줄어드는 것은 아니라 렌더링 성능 문제를 해결해주지는 못하기 때문이다.
그래서 테이블에 가상화(virtualization) 를 도입해서 렌더링 부담을 줄이는 방법을 택했고, 체감 성능이 눈에 띄게 올라갔다 🥳
일단 데이터가 많을때 렌더링이 느려지는 이유들에 대해 한 번 짚고 넘어가보자.
tr/td/div 등)즉, 데이터가 많다는 건 단순히 “map을 많이 돌린다”의 수준이 아니라 브라우저가 감당해야 하는 일이 기하급수로 늘어나게 된다.
가상화를 한 마디로 정의하면,
“화면에 보이는 row만 렌더링하고, 나머지는 렌더링하지 않는다.”
테이블이 10,000줄이어도 실제 DOM에는 예를 들어 80줄만 존재하게 만든다(보이는 영역 + 약간의 여유분).
사용자는 스크롤하면서 자연스럽게 10,000줄이 있는 것처럼 느끼지만, 실제로는 “보이는 구간만 갈아끼우는 방식” 으로 동작한다.
가상화의 기본동작은 이런식이다.
전체 row의 개수와 row 높이를 파악한다(또는 추정한다)
현재 스크롤 위치(scrollTop)를 파악한다
viewport 높이(보이는 영역 높이)를 파악한다
그러면 현재 화면에 보여야 하는 row index 구간을 계산한다
startIndex = floor(scrollTop / rowHeight)endIndex = startIndex + ceil(viewportHeight / rowHeight)start/end 근처에 여유분(overscan)을 조금 추가한다 (스크롤할 때 깜빡임 방지)
렌더링하지 않은 영역만큼의 “스크롤 공간”을 유지해줘야 하는데, 위/아래에 “빈 공간”을 spacer로 넣어서 스크롤 높이를 유지한다
여기서 spacer는 보통 두 가지 방식이 있는데,
paddingTop / paddingBottom으로 공간 만들기나는 두 번째(spacer row) 방법으로 만들었다.
type VirtualWindowResult<T> = {
visibleRecords: T[]
isVirtualized: boolean
startIndex: number
endIndex: number
topSpacerPx: number
bottomSpacerPx: number
}
export function computeVirtualWindow<T>(args: {
records: T[]
scrollTop: number
viewportHeight: number
rowHeight: number
overscan: number
threshold: number
makeSpacer: (id: string, heightPx: number) => T
}): VirtualWindowResult<T> {
const { records, scrollTop, viewportHeight, rowHeight, overscan, threshold, makeSpacer } = args
const total = records.length
if (total === 0 || total < threshold) {
return {
visibleRecords: records,
isVirtualized: false,
startIndex: 0,
endIndex: total,
topSpacerPx: 0,
bottomSpacerPx: 0,
}
}
const vp = viewportHeight || 400
const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
const endIndex = Math.min(total, startIndex + Math.ceil(vp / rowHeight) + overscan)
const topSpacerPx = startIndex * rowHeight
const bottomSpacerPx = (total - endIndex) * rowHeight
const slice = records.slice(startIndex, endIndex)
const visible: T[] = []
if (topSpacerPx > 0) visible.push(makeSpacer('__spacer_top', topSpacerPx))
visible.push(...slice)
if (bottomSpacerPx > 0) visible.push(makeSpacer('__spacer_bottom', bottomSpacerPx))
return {
visibleRecords: visible,
isVirtualized: true,
startIndex,
endIndex,
topSpacerPx,
bottomSpacerPx,
}
}
threshold는 “이 정도 row 이하면 굳이 가상화 안 한다” 같은 안전장치로, 데이터가 크지 않다면 가상화 없이 그냥 다 렌더링하는 게 오히려 버그가 적고 단순하다.overscan은 “현재 보이는 범위보다 위/아래로 추가 렌더링”이다.import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { computeVirtualWindow } from './computeVirtualWindow'
export function useVirtualWindow<T>(params: {
records: T[]
rowHeight: number
overscan?: number
threshold?: number
makeSpacer: (id: string, heightPx: number) => T
}) {
const { records, rowHeight, makeSpacer } = params
const overscan = params.overscan ?? 20
const threshold = params.threshold ?? 300
const viewportRef = useRef<HTMLDivElement | null>(null)
const [scrollTop, setScrollTop] = useState(0)
const [viewportHeight, setViewportHeight] = useState(400)
useLayoutEffect(() => {
const el = viewportRef.current
if (!el) return
const measure = () => setViewportHeight(el.clientHeight)
const onScroll = () => setScrollTop(el.scrollTop)
measure()
el.addEventListener('scroll', onScroll)
let ro: ResizeObserver | null = null
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(measure)
ro.observe(el)
}
window.addEventListener('resize', measure)
return () => {
el.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', measure)
if (ro) ro.disconnect()
}
}, [])
const windowResult = useMemo(() => {
return computeVirtualWindow<T>({
records,
scrollTop,
viewportHeight,
rowHeight,
overscan,
threshold,
makeSpacer,
})
}, [records, scrollTop, viewportHeight, rowHeight, overscan, threshold, makeSpacer])
return { viewportRef, ...windowResult }
}
여기까지가 가상화 계산을 위한 로직이고, 이를 실제 테이블 렌더링에 적용하면 된다.
import React, { useMemo } from 'react'
import { useVirtualWindow } from './useVirtualWindow'
type Row =
| { id: string; kind: 'spacer'; height: number }
| { id: string; kind: 'data'; name: string; amount: number }
const ESTIMATED_ROW_HEIGHT = 32
const OVERSCAN = 30
const THRESHOLD = 300
export default function VirtualizedTableDemo() {
const records = useMemo<Row[]>(() => {
return Array.from({ length: 10000 }).map((_, i) => ({
id: `row_${i}`,
kind: 'data',
name: `Row ${i}`,
amount: Math.floor(Math.random() * 100000),
}))
}, [])
const { viewportRef, visibleRecords, isVirtualized, startIndex, endIndex } = useVirtualWindow<Row>({
records,
rowHeight: ESTIMATED_ROW_HEIGHT,
overscan: OVERSCAN,
threshold: THRESHOLD,
makeSpacer: (id, heightPx) => ({ id, kind: 'spacer', height: heightPx }),
})
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8 }}>
<div style={{ padding: 12, borderBottom: '1px solid #eee', fontSize: 14 }}>
{isVirtualized ? (
<span>
가상화 켜짐 (렌더링 구간: {startIndex} ~ {endIndex})
</span>
) : (
<span>가상화 꺼짐 (row가 적어서 전체 렌더링)</span>
)}
</div>
{/* viewport */}
<div
ref={viewportRef}
style={{
height: 480,
overflow: 'auto',
position: 'relative',
}}
>
{/* table */}
<div style={{ minWidth: 600 }}>
{/* header (sticky) */}
<div
style={{
position: 'sticky',
top: 0,
zIndex: 1,
background: '#fff',
borderBottom: '1px solid #eee',
display: 'flex',
padding: '8px 12px',
fontWeight: 600,
}}
>
<div style={{ width: 200 }}>Name</div>
<div style={{ width: 120, textAlign: 'right' }}>Amount</div>
</div>
{/* body */}
{visibleRecords.map(row => {
if (row.kind === 'spacer') {
return <div key={row.id} style={{ height: row.height }} />
}
return (
<div
key={row.id}
style={{
display: 'flex',
padding: '8px 12px',
borderBottom: '1px solid #f3f3f3',
height: ESTIMATED_ROW_HEIGHT,
boxSizing: 'border-box',
}}
>
<div style={{ width: 200 }}>{row.name}</div>
<div style={{ width: 120, textAlign: 'right' }}>{row.amount.toLocaleString()}</div>
</div>
)
})}
</div>
</div>
</div>
)
}
크게 하면 스크롤이 부드럽긴 한데, 대신 렌더링 row 수가 늘어나게 된다.
데이터가 크지 않은데도 굳이 가상화를 적용하게 되면 오히려 자잘한 버그가 생길 수 있어서 threshold를 두는 것이 좋다.
가상화는 “DOM을 계속 갈아끼우는 방식”이라서, DOM에 의존하는 기능이 있으면 흔들릴 수 있다.
예를 들어
이런 건 상태를 DOM에 두지 않고 데이터 id 기반으로 관리하는 것이 안정적이다.
가상화를 도입하면서 대량 데이터 렌더링에서 발생하던 스크롤 지연과 반응성 문제는 해소되었지만, 더 개선할 수 있는 점들에 대해서도 생각해봤다.
현재 구현은 모든 row가 동일한 높이를 가진다는 가정 하에 동작하고 있다.
텍스트 길이나 컬럼 설정에 따라 row의 높이가 각각 달라질 수 있는 경우에는, 가변 높이를 지원하는 virtualization 구조가 필요하다.
다만, 가변 높이 가상화는 구현 복잡도가 크게 증가하고 유지보수 부담이 커지기 때문에 row 높이가 크게 변하지 않도록 UI를 설계하는 것이 더 현실적인 선택일 수 있다.
현재처럼 onScroll에서 setScrollTop를 계속 호출하면 리렌더가 잦아져서, 스크롤이 빠르게 발생할 경우 불필요한 계산이 증가하게 되고, 가상화로 줄인 렌더링 이점을 다시 감소시켜 버릴 수 있다.
requestAnimationFrame 기반으로 업데이트를 묶어 1프레임에 1번만 갱신하는 식으로 렌더링 빈도를 더 줄이는 방식으로 개선할 수 있을 것 같다.
가상 공간을 유지하기 위해 상·하단에 spacer row를 삽입하는 방식을 사용했는데, 이 방식은 구현이 직관적인 대신 테이블 기능과의 상호작용을 별도로 처리해야 한다.
예를 들어:
등이 필요하다.
(나는 rowKind === 'spacer'면 클릭 무시 처리하도록 했다.)
테이블 성능 문제를 해결할 때, 나는 순서를 보통 이렇게 가져간다.
그런데 데이터가 수천 줄 이상이고 기능이 많은 테이블이면 3번은 거의 필수에 가까운 것 같다. 그리고 가상화를 한 번 잘 붙여두면 이후에는 기능을 추가해도 성능이 잘 버텨주기 때문에 꽤 좋은 선택인듯 하다!