대용량 가상 스크롤 (Virtual Scroll)
현재 viewport에 필요한 행을 중심으로 렌더링하는 가상 스크롤(Virtual Scrolling)의 원리와 적용 시 주의사항을 알아봅니다.
import * as React from 'react';
import { notification } from 'antd';
import { AXDataGrid, AXDGColumn } from '@axboot/datagrid';
import type { AXDGCellSelectionCopyError } from '@axboot/datagrid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
interface Props {}
interface IListItem {
id: string;
title: string;
writer: string;
createAt: string;
}
const list = Array.from(Array(1000000)).map((v, i) => ({
values: {
id: `ID_${i}`,
title: `title_${i}`,
writer: `writer_${i}`,
createAt: `2022-09-08`,
},
}));
function ScrollExample(props: Props) {
const [notificationApi, contextHolder] = notification.useNotification();
const [columns, setColumns] = React.useState<AXDGColumn<IListItem>[]>(
Array.from({ length: 100 }, _ => {
return [
{
key: 'id',
label: '아이디',
width: 100,
},
{
key: 'title',
label: '제목',
width: 100,
},
{
key: 'writer',
label: '작성자',
width: 100,
},
{
key: 'createAt',
label: '작성일',
width: 100,
},
];
}).flat(),
);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width: containerWidth, height: containerHeight } = useContainerSize(containerRef);
const handleCopyError = React.useCallback(
(error: AXDGCellSelectionCopyError) => {
const description =
error.reason === 'maxClipboardCells'
? `선택된 셀이 ${formatNumber(error.actual)}개입니다. 최대 ${formatNumber(error.limit)}개까지만 복사할 수 있습니다.`
: error.reason === 'maxClipboardTextLength'
? `복사할 텍스트가 ${formatNumber(error.actual)}자입니다. 최대 ${formatNumber(error.limit)}자까지만 복사할 수 있습니다.`
: '브라우저가 클립보드 복사를 거부했습니다.';
notificationApi.warning({
message: '복사할 수 없습니다',
description,
placement: 'topRight',
});
},
[notificationApi],
);
return (
<DataGridContainer ref={containerRef}>
{contextHolder}
<AXDataGrid<IListItem>
width={containerWidth}
height={containerHeight}
data={list}
columns={columns}
cellSelectionOptions={{
onCopyError: handleCopyError,
}}
rowChecked={{
checkedIndexes: [],
onChange: (ids, selectedAll) => {
console.log('onChange rowSelection', ids, selectedAll);
},
}}
onClick={item => console.log(item)}
page={{
currentPage: 0,
totalPages: 0,
totalElements: list.length,
}}
/>
</DataGridContainer>
);
}
function formatNumber(value?: number) {
return value === undefined ? '-' : value.toLocaleString();
}
export default ScrollExample;import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* AXDataGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .axdg-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}1. 개요 및 가상 스크롤이 왜 필수적인가?
일반적인 HTML <table>에 1만 개 이상의 <tr>을 한 번에 렌더링하면 어떻게 될까요?
- 브라우저 멈춤(Freezing): 수만 개의 DOM 노드를 생성하고 계산하느라 메인 스레드가 수 초간 정지합니다.
- 엄청난 메모리 점유: DOM 노드 하나당 할당되는 메모리로 인해 탭이 다운(Crash)될 수 있습니다.
- 스크롤 버벅임(Jank): 스크롤 시 브라우저가 수만 개의 레이아웃을 다시 계산(Reflow/Repaint)하느라 프레임 드랍이 발생합니다.
AXBOOT DataGrid의 가상 스크롤(Virtual Scrolling) 로직은 height, itemHeight, 스크롤 위치를 이용해 현재 viewport 주변의 행을 계산하고 해당 범위를 렌더링합니다. 실제 렌더 행 수와 체감 성능은 그리드 높이, 셀 렌더러 복잡도, 브라우저 환경에 따라 달라집니다.
2. 가상 스크롤의 내부 계산 원리
AXBOOT DataGrid는 스크롤 이벤트 발생 시 다음과 같은 공식으로 렌더링할 행의 범위를 O(1) 시간 복잡도로 즉시 계산합니다:
1. 뷰포트 행 개수: displayItemCount = Math.ceil(height / itemHeight)
2. 시작 인덱스: startIndex = Math.floor(scrollTop / itemHeight)
3. 종료 인덱스: endIndex = startIndex + displayItemCount + 3 (버퍼 여유분)
4. 상단 여백 보정: topPadding = startIndex * itemHeight
가상화는 DOM 행 수를 줄이지만 전달한 원본 데이터 자체는 메모리에 존재합니다. 대용량 데이터에서는 초기 데이터 생성·전송 비용, 셀 렌더러 비용, 정렬·필터 처리 비용도 별도로 측정해야 합니다.
3. 실무 샘플 코드: 10,000건 대용량 거래 로그 뷰어
아래 코드는 10,000건의 로그 데이터를 즉시 생성하여 부드럽게 스크롤하는 완성된 예제입니다:
import React, { useMemo } from 'react';
import { AXDataGrid, type AXDGColumn, type AXDGDataItem } from '@axboot/datagrid';
interface LogItem {
id: number;
timestamp: string;
level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
service: string;
message: string;
latencyMs: number;
}
export default function LargeLogViewer() {
// 1. 10,000건의 mock 데이터 고속 생성
const data: AXDGDataItem<LogItem>[] = useMemo(() => {
const levels: LogItem['level'][] = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
const services = ['auth-service', 'order-api', 'payment-gateway', 'notification-worker'];
return Array.from({ length: 10000 }).map((_, i) => ({
values: {
id: i + 1,
timestamp: new Date(Date.now() - (10000 - i) * 1000).toISOString().replace('T', ' ').substring(0, 19),
level: levels[i % levels.length],
service: services[i % services.length],
message: `Request processed for user_session_${1000 + (i % 500)} with HTTP 200 OK`,
latencyMs: Math.floor(Math.random() * 450) + 10,
},
}));
}, []);
// 2. 컬럼 구성
const columns: AXDGColumn<LogItem>[] = [
{ key: 'id', label: '로그 ID', width: 90, align: 'center' },
{ key: 'timestamp', label: '발생 시각', width: 170, align: 'center' },
{
key: 'level',
label: '레벨',
width: 90,
align: 'center',
itemRender: ({ values }) => {
const colors = {
INFO: '#2563eb',
WARN: '#d97706',
ERROR: '#dc2626',
DEBUG: '#64748b',
};
return (
<span style={{ fontWeight: 700, color: colors[values.level] }}>
{values.level}
</span>
);
},
},
{ key: 'service', label: '서비스명', width: 160 },
{ key: 'message', label: '로그 메시지', width: 380 },
{
key: 'latencyMs',
label: '응답시간(ms)',
width: 120,
align: 'right',
itemRender: ({ values }) => (
<span style={{ color: values.latencyMs > 300 ? '#dc2626' : '#16a34a', fontWeight: 600 }}>
{values.latencyMs} ms
</span>
),
},
];
return (
<div>
<div style={{ marginBottom: 12, fontSize: 14, color: '#475569' }}>
총 <strong>{data.length.toLocaleString()}</strong>건의 실시간 로그 데이터가 가상 스크롤로 로드되었습니다.
</div>
<AXDataGrid<LogItem>
width={850}
height={450} // 뷰포트 높이 고정 (필수)
columns={columns}
data={data}
rowKey="id"
itemHeight={28} // 행 높이 지정 (기본 25~28px 권장)
headerHeight={34}
/>
</div>
);
}
4. 고성능 렌더링을 위한 실무 최적화 팁
1) itemRender 내부에서 무거운 계산이나 훅 호출 금지
가상 스크롤 시 스크롤 위치가 바뀔 때마다 뷰포트 안의 행들이 빠르게 리렌더링됩니다.
itemRender 콜백 안에서 무거운 정규식 파싱, 대용량 배열 필터링, 새로운 객체 대량 생성을 피하고 단순한 포맷팅 위주로 작성하세요.
2) itemHeight를 데이터 내용에 맞게 정확히 지정
각 행의 높이가 itemHeight와 불일치하면 스크롤바 이동 시 미세한 덜컥거림이 생길 수 있습니다. 디자인 시안에 맞추어 itemHeight={28} 또는 32처럼 명시적 높이를 고정하세요.
3) 부모 컨테이너 크기 변경 감지 (useContainerSize)
화면 전체를 채우는 대시보드에서는 고정 픽셀 대신 컨테이너 크기 측정 훅을 사용하여 width와 height를 전달하면 창 크기 조절 시에도 가상 스크롤 범위가 매끄럽게 재계산됩니다.
5. 자주 묻는 질문 (FAQ)
Q. 가상 스크롤이 적용되면 브라우저 검색(Ctrl + F)은 어떻게 되나요? 가상 스크롤 테이블은 현재 뷰포트에 보이는 행만 DOM에 존재하므로 브라우저 기본 Ctrl+F는 화면 밖의 데이터를 찾을 수 없습니다. 대용량 데이터에서 검색이 필요한 경우 헤더 툴박스 필터링 기능을 사용하여 그리드 자체 필터를 제공하는 것이 표준적인 방법입니다.