DataGrid 성능 비교
100만 행 브라우저 workload에서 opt-datagrid와 TanStack Table v9 + TanStack Virtual을 직접 측정했습니다. 결과뿐 아니라 fixture, 측정 코드, 재현 명령과 한계까지 공개합니다.
reopt design업데이트
Reference snapshot · 2026-07-14
1,000,000 rows × 8 columns
동일한 데이터, viewport, overscan에서 retained heap과 보이는 행 업데이트를 측정했습니다.
74.3% less
초기 retained heap
98.43 MB vs 383.53 MB
33.9× faster
visible-row update → paint
10.60 ms vs 359.10 ms
84.4% less
업데이트 후 retained heap
103.11 MB vs 660.72 MB
1. 직접 비교 결과
아래 값은 3회 측정의 중앙값입니다. retained heap은 강제 GC 뒤에도 살아 있는 JavaScript heap이며, update → paint는 마지막 보이는 행의 객체를 교체한 시점부터 변경된 셀 텍스트가 두 animation frame을 거쳐 화면에 반영될 때까지의 시간입니다.
| 측정 구간 | opt-datagrid | TanStack v9 | 차이 |
|---|---|---|---|
| 초기 retained JS heap | 98.43 MB | 383.53 MB | 74.3% less |
| 중간 스크롤 retained JS heap | 99.18 MB | 384.68 MB | 74.2% less |
| 끝 스크롤 retained JS heap | 99.23 MB | 384.77 MB | 74.2% less |
| 보이는 마지막 행 update → paint | 10.60 ms | 359.10 ms | 33.9× faster |
| 업데이트 후 retained JS heap | 103.11 MB | 660.72 MB | 84.4% less |
두 fixture 모두 끝 스크롤에서 row index 999,999를 렌더했습니다.
opt-datagrid window는 999,971–999,999, TanStack fixture는 999,970–999,999였습니다. 브라우저 layout height 한계로 중간 행에서 멈춘 결과를 성공으로 계산하지 않았습니다.
2. 왜 빠른가
차이는 단순한 DOM row 수보다 데이터 전체를 어떤 형태로 메모리에 materialize하고, rows 변경을 어디까지 전파하는지에서 크게 발생합니다. 다음 설명은 이 benchmark workload와 현재 구현을 기준으로 합니다.
전체 row model을 만들지 않는 fixed-height window
숫자형 rowHeight 경로는 rowIndex × rowHeight로 offset을 계산합니다. 100만 개 행마다 별도 model과 offset 객체를 만들 필요가 없습니다.
브라우저 높이 한계를 넘는 bounded scroll track
논리 높이가 커져도 native scroll track은 16,000,000px 이하로 유지하고 logical scroll 위치로 환산합니다. 마지막 행 999,999 렌더 검증이 이 경로를 함께 검사합니다.
현재 보이는 window만 업데이트
rows 배열이 교체되면 현재 startRow–endRow와 렌더된 column만 비교해 version을 올립니다. 화면 밖 99만여 행을 update fan-out에 포함하지 않습니다.
접근한 셀만 유지하는 lazy value cache
value cache는 실제 렌더 과정에서 읽은 셀만 생성합니다. stable row ID가 있으면 기존 entry의 저장된 index를 확인해 재사용합니다.
packages/opt-datagrid/src/core/layout/row-window.tspackages/opt-datagrid/src/data-grid.tsxpackages/opt-datagrid/src/core/value-cache.ts3. 공정성을 위한 실험 설계
고정한 조건
- 동일한 deterministic 1,000,000행 × 8열 데이터와 표시 문자열
- 동일한 React 19.2.5, 1,440 × 900 viewport, overscan 5
- 각 대상을 별도 production Vite bundle과 별도 preview server로 실행
- manifest와 source map을 검사해 상대 grid 모듈 혼입 시 즉시 실패
- 각 system을 별도 Chromium process에서 실행하고 heavy sample마다 재시작
sample마다 수행하는 단계
- fixture ready 후 두 animation frame을 기다립니다.
- CDP HeapProfiler.collectGarbage를 실행한 뒤 retained JS heap과 DOM counter를 읽습니다.
- initial, 50% scroll, 100% scroll 상태를 각각 측정합니다.
- 끝 스크롤에서 실제 row index 999,999가 DOM에 있는지 검증합니다.
- 현재 보이는 마지막 행 하나를 교체하고 새 텍스트가 paint될 때까지 측정합니다.
- 세 번의 sample 중앙값을 JSON, CSV, Markdown으로 출력합니다.
양쪽 fixture는 동일한 row 배열 복사와 row 객체 교체를 수행합니다. 따라서 100만 원소 outer-array 복사 비용은 두 결과에 모두 포함됩니다. opt fixture는 기본 34px row height, TanStack Virtual fixture는 33px estimate와 공통 CSS의 33px 최소 cell height를 사용합니다.
opt fixture는 getRowId, valueCache, valueCacheStrategy="row-id"와 165px row buffer를 사용합니다. 비교 fixture는 TanStack Table v9의 sizing, visibility, sorting feature와 TanStack Virtual의 overscan 5를 사용합니다. 두 설정은 동일 API를 흉내 내기보다 각 도구의 권장 virtualized-grid 구성을 명시적으로 고정한 것입니다.
4. 실제 측정 코드
표시용 pseudo benchmark가 아니라 이 저장소에서 결과를 만든 runner의 핵심 코드입니다. GC 뒤 CDP metric을 읽고, 마지막 실제 data row가 렌더되지 않으면 sample을 실패시킵니다.
// run.ts의 measure() 핵심 구간
async function measure(client, page, benchmarkCase) {
await page.evaluate(() => new Promise(requestAnimationFrame));
await page.evaluate(() => new Promise(requestAnimationFrame));
const beforeGc = await client.send("Performance.getMetrics");
await client.send("HeapProfiler.collectGarbage");
await page.waitForTimeout(100);
const [{ metrics }, domCounters, rendered] = await Promise.all([
client.send("Performance.getMetrics"),
client.send("Memory.getDOMCounters"),
page.evaluate(() => {
const renderedRowIndexes = Array.from(
document.querySelectorAll<HTMLElement>("[data-row-index]"),
(element) => Number(element.dataset.rowIndex),
).filter(Number.isFinite);
return {
maxRenderedRowIndex:
renderedRowIndexes.length > 0
? Math.max(...renderedRowIndexes)
: -1,
minRenderedRowIndex:
renderedRowIndexes.length > 0
? Math.min(...renderedRowIndexes)
: -1,
};
}),
]);
return { beforeGc, metrics, domCounters, rendered };
}
if (endMeasurement.maxRenderedRowIndex !== benchmarkCase.rows - 1) {
throw new Error(
`end-scroll stopped at row ${endMeasurement.maxRenderedRowIndex}`,
);
}// isolation.ts의 manifest / production bundle 검사
if ([...optDependencies].some((name) => name.startsWith("@tanstack/"))) {
throw new Error("opt-datagrid fixture must not depend on TanStack modules");
}
if (tanstackDependencies.has("@reopt-ai/opt-datagrid")) {
throw new Error(
"TanStack fixture must not depend on @reopt-ai/opt-datagrid",
);
}
const sources = sourceMapSources(fixtureDirectory(workspaceRoot, system));
const normalized = sources.join("\n").toLowerCase();
if (
system === "opt-datagrid" &&
(normalized.includes("@tanstack") || normalized.includes("tanstack/"))
) {
throw new Error("opt-datagrid bundle contains TanStack source modules");
}아래 경로는 이 페이지에 첨부한 코드의 repository 원본 위치입니다. 문서 무결성 테스트가 각 파일의 존재와 snapshot package version을 함께 검증합니다.
benchmarks/datagrid-memory-runner/src/run.tsbenchmarks/datagrid-memory-runner/src/isolation.tsbenchmarks/datagrid-memory-opt/src/main.tsxbenchmarks/datagrid-memory-tanstack/src/main.tsxbenchmarks/datagrid-memory-shared/src/index.ts5. 실행 환경과 범위
| CPU / memory | Apple M2 Max · 12 logical CPUs · 32 GB |
|---|---|
| Operating system | macOS · Darwin 25.5.0 |
| Bun / Node.js | Bun 1.3.14 · Node.js 24.3.0 |
| Browser driver | Playwright 1.58.2 · bundled Chromium |
| opt-datagrid | 1.4.2 |
| Comparison | TanStack Table 9.0.0-beta.31 + TanStack Virtual 3.14.6 |
이 결과는 특정 workload의 reference snapshot입니다.
두 라이브러리의 feature set과 DOM 구조는 같지 않으며 TanStack Table 대상은 v9 beta입니다. 머신, 브라우저, dependency, fixture가 달라지면 절대값도 달라집니다. 보편적인 순위나 모든 앱의 성능 보증으로 해석하지 마세요.
6. 직접 재현하기
repository root에서 아래 명령을 실행합니다. 각 target은 독립적으로 build되고 결과는 runner의 results/ 아래 JSON, CSV, Markdown으로 생성됩니다.
# 빠른 계약 검증
bun run --filter @reopt/benchmark-datagrid-memory-runner benchmark:smoke
# 이 페이지의 1,000,000 × 8 reference case 재현
bun run --filter @reopt/benchmark-datagrid-memory-runner benchmark -- --case rows-1000000x8 --caseTimeoutMs 60000
# 전체 row/column matrix, 각 case 3회
bun run --filter @reopt/benchmark-datagrid-memory-runner benchmark