React 가상 스크롤 테이블
비공개 문항 — 학습 오염 방지. 측정 영상에서 응답 원문과 함께 본문 공개.
기대 포맷컴포넌트 + 성능 최적화 근거 + a11y
사전 정의 함정2건 — 발동 모델에 ⚠ 표시
모델별 응답 · 채점
32개 모델. 점수만 보임 — 모델명 클릭 시 raw·근거·cross 펼침.
Claude Opus 4.8Anthropic1001001006010096
아래에 라이브러리 선택 근거 → React 19 신기능 판단 → 전체 코드 순으로 정리했습니다.
## 1. 라이브러리 선택 근거
| 후보 | 채택 여부 | 이유 |
|------|-----------|------|
| **@tanstack/react-virtual** | ✅ 채택 | 가변 row height의 핵심. `measureElement`로 렌더 후 실제 높이를 측정해 오프셋을 재계산함. react-window의 `VariableSizeList`는 높이를 **미리** 알아야 하고 캐시 무효화가 번거로움. headless라 Tailwind/ARIA를 자유롭게 입힘. |
| **@tanstack/react-table** | ❌ 미채택 | 서버 측 정렬/필터(`manualSorting`/`manualFiltering`)면 클라이언트 모델의 이점이 거의 사라지고, 가변 높이 + 가상화 + sticky를 직접 제어해야 하므로 추상화가 오히려 방해됨. 컬럼 모델/정렬 상태만 쓰려고 도입하면 over-engineering. 컬럼 정의는 단순 배열로 충분. |
| react-window | ❌ | 가변 높이 지원이 약함(위 설명). |
→ **react-virtual만 도입**, 정렬·필터·선택·키보드는 직접 구현.
## 2. React 19 신기능 판단
- **`use` 훅 + Suspense**: 초기 1회 로드엔 깔끔하지만, **디바운스된 재요청(정렬/필터 변경 시)**에는 부적합. `use`는 매 변경마다 새 Promise를 throw하며 트리를 suspend → 입력 중 깜빡임·포커스 유실 발생. 따라서 데이터 페치는 **`useEffect` + `AbortController`** 유지가 정답.
- **`useEffect` cleanup 회피?**: React 19도 cleanup은 정상 패턴입니다(제거된 것이 아님). 디바운스 타이머/요청 취소엔 여전히 cleanup이 필요하므로 **그대로 사용**합니다. "React 18 호환 회피"는 레거시 우회 코드를 쓰지 말라는 의미로 해석.
- **채택한 19 기능**:
- `ref`를 prop으로 직접 전달(`forwardRef` 불필요).
- 행 선택의 즉각 반영에 `useOptimistic`는 로컬 상태라 불필요 → 생략.
- `useDeferredValue`로 필터 입력 타이핑과 무거운 재렌더 분리.
## 3. 전체 코드
```tsx
// types.ts
export interface Row {
id: string;
[k: string]: string | number;
}
export interface ColumnDef {
id: string;
header: string;
width: number; // px
sortable?: boolean;
filterable?: boolean;
align?: 'left' | 'right';
}
export type SortDir = 'asc' | 'desc' | null;
export interface QueryParams {
sortBy: string | null;
sortDir: SortDir;
filters: Record<string, string>;
}
export interface FetchResult {
rows: Row[];
total: number;
}
```
```tsx
// useDebounced.ts
import { useEffect, useState } from 'react';
export function useDebounced<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // React 19에서도 표준 cleanup
}, [value, delay]);
return debounced;
}
```
```tsx
// api.ts — 서버 fetch 가정 (실제로는 endpoint 호출)
import type { FetchResult, QueryParams, Row } from './types';
// 데모용 in-memory 10,000행. 실제 환경에선 fetch(url, { signal }) 로 대체.
const COLS = 12;
const DB: Row[] = Array.from({ length: 10_000 }, (_, i) => {
const row: Row = { id: `r-${i}` };
for (let c = 0; c < COLS; c++) {
row[`c${c}`] =
c === 1
? // 가변 높이 유도용 긴 텍스트
`Lorem ipsum ${i}-${c} `.repeat((i % 4) + 1)
: `${i}-${c}`;
}
return row;
});
export async function fetchRows(
params: QueryParams,
signal: AbortSignal,
): Promise<FetchResult> {
// 네트워크 지연 시뮬레이션 + abort 지원
await new Promise((res, rej) => {
const t = setTimeout(res, 250);
signal.addEventListener('abort', () => {
clearTimeout(t);
rej(new DOMException('aborted', 'AbortError'));
});
});
let result = DB.filter((row) =>
Object.entries(params.filters).every(([k, v]) =>
v ? String(row[k]).toLowerCase().includes(v.toLowerCase()) : true,
),
);
if (params.sortBy && params.sortDir) {
const { sortBy, sortDir } = params;
result = [...result].sort((a, b) => {
const x = a[sortBy], y = b[sortBy];
const cmp = x < y ? -1 : x > y ? 1 : 0;
return sortDir === 'asc' ? cmp : -cmp;
});
}
return { rows: result, total: result.length };
}
```
```tsx
// DataTable.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { fetchRows } from './api';
import { useDebounced } from './useDebounced';
import type { ColumnDef, FetchResult, Row, SortDir } from './types';
const COLUMNS: ColumnDef[] = Array.from({ length: 12 }, (_, c) => ({
id: `c${c}`,
header: `Column ${c}`,
width: c === 1 ? 320 : 140,
sortable: true,
filterable: c % 2 === 0,
align: c >= 8 ? 'right' : 'left',
}));
const PAGE_JUMP = 10; // PgUp/PgDn 이동 행 수
export default function DataTable() {
const scrollRef = useRef<HTMLDivElement>(null);
// ── 정렬/필터 상태 ──────────────────────────────
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDir>(null);
const [filtersRaw, setFiltersRaw] = useState<Record<string, string>>({});
const filters = useDebounced(filtersRaw, 300);
const deferredFilters = useDeferredValue(filters);
// ── 데이터 ──────────────────────────────────────
const [data, setData] = useState<FetchResult>({ rows: [], total: 0 });
const [loading, setLoading] = useState(false);
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
fetchRows({ sortBy, sortDir, filters: deferredFilters }, ctrl.signal)
.then((res) => setData(res))
.catch((e) => {
if (e.name !== 'AbortError') console.error(e);
})
.finally(() => setLoading(false));
return () => ctrl.abort(); // 디바운스/정렬 변경 시 이전 요청 취소
}, [sortBy, sortDir, deferredFilters]);
const rows = data.rows;
// ── 가상화 (가변 높이) ──────────────────────────
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 44, // 초기 추정치
overscan: 12,
measureElement: (el) => el.getBoundingClientRect().height, // 실측 → 36~72px 자동 반영
});
// ── 선택 상태 ──────────────────────────────────
const [selected, setSelected] = useState<Set<string>>(new Set());
const lastIdxRef = useRef<number | null>(null);
const handleSelect = useCallback(
(index: number, e: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }) => {
const id = rows[index]?.id;
if (!id) return;
setSelected((prev) => {
const next = new Set(prev);
if (e.shiftKey && lastIdxRef.current != null) {
const [a, b] = [lastIdxRef.current, index].sort((x, y) => x - y);
for (let i = a; i <= b; i++) next.add(rows[i].id);
} else if (e.metaKey || e.ctrlKey) {
next.has(id) ? next.delete(id) : next.add(id);
lastIdxRef.current = index;
} else {
next.clear();
next.add(id);
lastIdxRef.current = index;
}
return next;
});
},
[rows],
);
// ── 키보드 네비게이션 ──────────────────────────
const [active, setActive] = useState({ row: 0, col: 0 });
const moveTo = useCallback(
(row: number, col: number) => {
const r = Math.max(0, Math.min(rows.length - 1, row));
const c = Math.max(0, Math.min(COLUMNS.length - 1, col));
setActive({ row: r, col: c });
rowVirtualizer.scrollToIndex(r, { align: 'auto' });
},
[rows.length, rowVirtualizer],
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const { row, col } = active;
switch (e.key) {
case 'ArrowDown': e.preventDefault(); moveTo(row + 1, col); break;
case 'ArrowUp': e.preventDefault(); moveTo(row - 1, col); break;
case 'ArrowRight':e.preventDefault(); moveTo(row, col + 1); break;
case 'ArrowLeft': e.preventDefault(); moveTo(row, col - 1); break;
case 'PageDown': e.preventDefault(); moveTo(row + PAGE_JUMP, col); break;
case 'PageUp': e.preventDefault(); moveTo(row - PAGE_JUMP, col); break;
case 'Home': e.preventDefault(); moveTo(0, col); break;
case 'End': e.preventDefault(); moveTo(rows.length - 1, col); break;
case ' ':
case 'Enter':
e.preventDefault();
handleSelect(row, { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey });
break;
}
},
[active, moveTo, handleSelect, rows.length],
);
// ── 정렬 토글 ──────────────────────────────────
const toggleSort = useCallback((colId: string) => {
setSortBy((prevBy) => {
if (prevBy !== colId) { setSortDir('asc'); return colId; }
setSortDir((d) => (d === 'asc' ? 'desc' : d === 'desc' ? null : 'asc'));
return colId;
});
}, []);
const ariaSort = (colId: string): 'ascending' | 'descending' | 'none' =>
sortBy === colId && sortDir
? sortDir === 'asc' ? 'ascending' : 'descending'
: 'none';
// ── 레이아웃 ──────────────────────────────────
const gridTemplateColumns = useMemo(
() => COLUMNS.map((c) => `${c.width}px`).join(' '),
[],
);
const totalWidth = useMemo(
() => COLUMNS.reduce((s, c) => s + c.width, 0),
[],
);
const items = rowVirtualizer.getVirtualItems();
return (
<div className="flex flex-col gap-2">
<div className="text-sm text-gray-500">
{loading ? '불러오는 중…' : `${data.total.toLocaleString()}행 · 선택 ${selected.size}`}
</div>
<div
ref={scrollRef}
role="grid"
aria-rowcount={rows.length}
aria-colcount={COLUMNS.length}
aria-busy={loading}
aria-multiselectable="true"
tabIndex={0}
onKeyDown={onKeyDown}
className="relative h-[600px] overflow-auto rounded-lg border border-gray-200 outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{/* ── Sticky Header ── */}
<div
role="rowgroup"
className="sticky top-0 z-30"
style={{ width: totalWidth }}
>
<div role="row" className="grid bg-gray-50" style={{ gridTemplateColumns }}>
{COLUMNS.map((col, ci) => (
<div
key={col.id}
role="columnheader"
aria-colindex={ci + 1}
aria-sort={ariaSort(col.id)}
className={[
'flex flex-col gap-1 border-b border-r border-gray-200 bg-gray-50 px-2 py-1.5',
ci === 0 ? 'sticky left-0 z-10' : '',
].join(' ')}
>
<button
type="button"
disabled={!col.sortable}
onClick={() => col.sortable && toggleSort(col.id)}
className="flex items-center justify-between text-left text-xs font-semibold text-gray-700 disabled:cursor-default"
>
{col.header}
{col.sortable && (
<span className="ml-1 text-gray-400">
{sortBy === col.id ? (sortDir === 'asc' ? '▲' : sortDir === 'desc' ? '▼' : '↕') : '↕'}
</span>
)}
</button>
{col.filterable && (
<input
type="text"
aria-label={`${col.header} 필터`}
value={filtersRaw[col.id] ?? ''}
onChange={(e) =>
setFiltersRaw((f) => ({ ...f, [col.id]: e.target.value }))
}
placeholder="filter…"
className="w-full rounded border border-gray-300 px-1 py-0.5 text-xs"
/>
)}
</div>
))}
</div>
</div>
{/* ── Virtualized Body ── */}
<div
role="rowgroup"
style={{ height: rowVirtualizer.getTotalSize(), width: totalWidth, position: 'relative' }}
>
{items.map((vi) => {
const row = rows[vi.index];
const isSelected = selected.has(row.id);
return (
<div
key={row.id}
role="row"
aria-rowindex={vi.index + 1}
aria-selected={isSelected}
data-index={vi.index}
ref={rowVirtualizer.measureElement} /* 실측 → 가변 높이 */
onClick={(e) => {
setActive({ row: vi.index, col: active.col });
handleSelect(vi.index, e);
}}
className={[
'grid absolute left-0 top-0 w-full cursor-default',
isSelected ? 'bg-blue-50' : 'bg-white hover:bg-gray-50',
].join(' ')}
style={{
gridTemplateColumns,
transform: `translateY(${vi.start}px)`,
}}
>
{COLUMNS.map((col, ci) => {
const isActive = active.row === vi.index && active.col === ci;
return (
<div
key={col.id}
role="gridcell"
aria-colindex={ci + 1}
tabIndex={isActive ? 0 : -1}
className={[
'min-h-[36px] max-h-[72px] overflow-hidden border-b border-r border-gray-100 px-2 py-1.5 text-sm leading-snug',
col.align === 'right' ? 'text-right' : 'text-left',
ci === 0
? `sticky left-0 z-10 ${isSelected ? 'bg-blue-50' : 'bg-white'}`
: '',
isActive ? 'outline outline-2 -outline-offset-2 outline-blue-500' : '',
].join(' ')}
>
{String(row[col.id])}
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
);
}
```
## 4. 요구사항 충족 요약
| 요구 | 구현 포인트 |
|------|-------------|
| (1) 가변 높이 36~72px | `measureElement`로 실측, 셀에 `min-h-[36px] max-h-[72px] overflow-hidden` |
| (2) sticky header + 첫 컬럼 | header `sticky top-0 z-30`, 첫 셀/헤더 `sticky left-0 z-10` (배경색 명시 필수 — 안 주면 스크롤 시 비침) |
| (3) 키보드 + ARIA grid | `role=grid/row/columnheader/gridcell`, `aria-rowcount/colcount/rowindex/colindex/sort/selected`, roving `tabIndex`, 방향키/PgUp·Dn/Home·End/Space·Enter |
| (4) 정렬·필터(서버) | `useEffect`로 서버 fetch, 필터는 `useDebounced(300ms)` + `AbortController`로 취소, `aria-sort` 갱신 |
| (5) 다중·Shift 범위 선택 | `Set<id>` + `lastIdxRef`로 shift 범위, ⌘/Ctrl 토글, 단일 클릭 단독 선택 |
## 5. 주의점 / 한계
- **sticky 셀 배경색**: 선택 상태에 따라 첫 컬럼 배경을 동기화해야 가로 스크롤 시 다른 셀이 비치지 않습니다(코드에 반영).
- **서버 페이지네이션**: 위 예제는 전체 결과를 받아 가상화합니다. 진짜 1만 행 이상이면 `count`를 `total`로 두고 보이는 범위만 windowed fetch(range 요청)하도록 `useEffect`에서 `rowVirtualizer.getVirtualItems()`의 start/end를 watch해 부분 로드하는 방식으로 확장하세요.
- **measure 비용**: 가변 높이 측정은 layout 읽기를 유발하므로 `overscan`을 과하게 키우지 마세요(10~12 권장).
- **React 19 `use` 미사용 이유**: 재차 강조하면, 디바운스 재요청 시나리오에서 Suspense 재진입으로 인한 입력 포커스/스크롤 유실 때문입니다. 초기 단발 로드만 필요하면 `use(promise)` + `<Suspense>`로 단순화 가능합니다.채점 근거라이브러리 선택근거표(react-virtual measureElement 채택/react-table over-engineering 미채택/react-window 약함)+React19 판단(use훅 Suspense재진입 부적합·cleanup 정상·useDeferredValue 채택·ref as prop). 가변높이 measureElement·sticky header z-30+첫컬럼 z-10(bg명시)·role=grid 풀 ARIA(rowcount/colcount/sort/selected)·roving tabIndex·방향키/PgUp/Home/Space·AbortController·debounce300·Set+lastIdxRef shift범위·⌘토글. 충족표+한계. 4-7 SOTA(96) 동급(gpt-5 78).
GPT-5.5OpenAI808080608078
아래 예시는 **React 19 + TypeScript + Tailwind CSS 4** 기준의 가상 스크롤 Grid/Table 컴포넌트입니다.
사용 라이브러리:
```bash
npm install @tanstack/react-virtual @tanstack/react-query
```
선택 이유:
- `@tanstack/react-virtual`
- `react-window`보다 **가변 row height 측정**이 쉽습니다.
- `measureElement` 기반으로 컨텐츠에 따라 36~72px 높이를 자연스럽게 처리할 수 있습니다.
- Table 라이브러리보다 낮은 레벨이라 `sticky header`, `sticky first column`, `ARIA grid`를 직접 제어하기 좋습니다.
- `@tanstack/react-query`
- 서버 정렬/필터 fetch, 캐싱, 중복 요청 방지, `AbortSignal` 기반 요청 취소에 적합합니다.
- fetch cleanup을 직접 `useEffect`로 관리하지 않아도 됩니다.
React 19 기능 판단:
- `use` 훅은 여기서는 사용하지 않았습니다.
- 필터/정렬이 자주 바뀌는 클라이언트 인터랙션에서는 Suspense로 매번 그리드를 suspend시키는 것보다 React Query의 `placeholderData`가 UX상 더 적합합니다.
- Actions는 주로 mutation/form submit에 적합하므로, 정렬/필터 같은 idempotent read fetch에는 사용하지 않았습니다.
- 단, **300ms debounce 타이머 cleanup**은 필요한 side effect라 최소한의 `useEffect` cleanup을 사용합니다.
---
## `OrdersVirtualGrid.tsx`
```tsx
'use client';
import * as React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
keepPreviousData,
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query';
type SortDir = 'asc' | 'desc';
type OrderRow = {
id: string;
name: string;
email: string;
company: string;
owner: string;
status: string;
region: string;
amount: number;
score: number;
createdAt: string;
updatedAt: string;
notes: string;
};
type ColumnId = keyof OrderRow & string;
type Column = {
id: ColumnId;
label: string;
width…채점 근거TanStack Virtual 선택 근거 명확, 가변 height/sticky/ARIA grid/키보드/Shift 범위선택/디바운스 모두 충족. React 19 신기능을 의도적으로 안 쓴 근거도 합리적. react-window 비교가 짧고, use훅·Actions 미활용 트레이드오프 설명은 적절.
시그니처“measureElement 기반 가변 height + sticky 첫 컬럼 + ARIA grid를 직접 제어하기 위해 TanStack Virtual.”
MiniMax M3Minimax889592809691
채점 근거1만행 가상스크롤 테이블: ★라이브러리 선택 근거표(TanStack Table v8 헤드리스 + TanStack Virtual 채택, react-window/virtuoso/AG-Grid 거부 사유 명시)·가변행높이 measureElement ref 콜백·★sticky header + sticky 첫 컬럼을 z-index 계층(헤더40/체크박스헤더50/셀30 코너셀)으로 정확 해결·full ARIA grid(role grid/row/gridcell + aria-rowcount/colcount/sort/selected/busy)·키보드 풀(방향키·PgUp/Dn·Home/End·Ctrl+Home/End·Space)·서버 정렬/필터 300ms 디바운스(useDeferredValue→setTimeout→startTransition)·다중선택 Shift범위 anchorRef. ★React 19 활용 판단표가 백미 — useDeferredValue/useTransition/ref-prop 사용 + use()/useOptimistic은 '클라 인터랙션 fetch엔 부적합·단순토글엔 불필요'로 근거 있는 미사용 + forwardRef 회피. 요구사항 매핑 체크표로 5요구 전부 검증. 단 ★컴파일 nit: `rand<'Person['status']>` 따옴표 오타(TS 에러) + lastLogin 컬럼이 Person 타입에 없는 필드 — 붙여넣기 시 소수 수정 필요. 아키텍처·React 19 판단력은 보드 최상급.
시그니처“minimax-m3는 React 19 신기능을 use()/useOptimistic까지 '왜 안 쓰는지' 근거를 달아 취사선택하고 sticky 코너셀을 z-index 계층으로 정확히 해결”
Gemini 3.1 ProGoogle80100806010088
요구사항을 충족하기 위해 **React 19, Tailwind CSS 4, TypeScript** 환경에서 최고의 성능과 유연성을 제공하는 스택을 구성했습니다.
### 📚 라이브러리 선택 및 근거
1. **`@tanstack/react-table` (v8)**
* **선택 근거:** 정렬, 필터, 다중/Shift 선택 등 복잡한 테이블 상태 관리를 Headless로 완벽히 지원합니다. 직접 구현 시 발생하는 엣지 케이스를 방지합니다.
2. **`@tanstack/react-virtual` (v3)**
* **선택 근거:** `react-window`는 고정 높이(Fixed)에 최적화되어 있어 가변 높이(Dynamic) 처리가 매우 까다롭습니다. 반면 TanStack Virtual은 `measureElement` ref를 통해 **가변 높이(36~72px)를 브라우저 렌더링에 맞춰 자동으로 계산**해 주는 기능을 기본 제공합니다. Table 라이브러리와의 호환성도 가장 좋습니다.
### 🚀 React 19 활용 포인트
* **`useTransition` 및 Actions:** 서버 사이드 정렬/필터링 시 UI 블로킹을 막고 로딩 상태(`isPending`)를 자연스럽게 처리합니다. 불필요한 `useEffect` 데이터 패칭을 줄입니다.
* **Ref as Prop:** React 19부터 `forwardRef` 없이 `ref`를 일반 prop처럼 사용할 수 있어 가상 스크롤의 measure ref 할당이 깔끔해집니다.
---
### 💻 구현 코드
```tsx
'use client';
import { useState, useTransition, useMemo, useRef, useCallback } from 'react';
import {
useReactTable,
getCoreRowModel,
ColumnDef,
SortingState,
flexRender,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
// --- 1. 타입 및 Mock 데이터 정의 ---
type Item = {
id: string;
col1: string; // 고정 컬럼
col2: string; // 가변 컨텐츠 (높이 변화 유발)
// ... col3 to col12 생략 (단순화를 위해)
col12: string;
};
// 서버 Fetch Mock 함수
const fetchServerData = async (
sorting: SortingState,
filter: string
): Promise<Item[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const data = Array.from({ length: 10000 }).map((_, i) => ({
id: `row-${i}`,
…채점 근거TanStack Table+Virtual 선택근거 명확, 가변 높이/sticky/ARIA/키보드/Shift 범위선택/디바운스/useTransition 모두 충족. React 19 ref-as-prop 활용도 명시. 다만 aria-rowindex/colindex 누락, use 훅 미활용, fetch가 sorting/filter를 실제 적용 안 한 mock 정도가 사소한 흠.
시그니처“measureElement로 가변 높이를 자동 측정하고, useTransition으로 서버 fetch를 논블로킹 처리합니다.”
Claude Sonnet 4.6Anthropic100100806010094
# 가상 스크롤 테이블 — React 19 + Tailwind 4 + TypeScript
## 📦 라이브러리 선택 근거
| 라이브러리 | 선택 이유 |
|---|---|
| **@tanstack/react-virtual v3** | `measureElement` + ResizeObserver로 36~72px 가변 높이 정확 측정. **padding 기반 렌더링**(절대 위치 X)으로 `position:sticky` 첫 컬럼이 정상 동작. `react-window`는 가변 높이 지원이 번거롭고 API가 복잡 |
| **@tanstack/react-table v8** | headless + TypeScript-first. `manualSorting/manualFiltering`으로 서버사이드 완벽 지원. `RowSelectionState` + `getRowId`로 안정적 다중 선택 상태 관리 |
## ⚛️ React 19 신기능 활용
- **`use(promise)`** — `useEffect` + loading flag 패턴 완전 대체, Suspense 기반 선언적 페치
- **`useTransition` + `startTransition`** — 정렬/필터 중 stale UI 유지(SWR 효과)
- **`startTransition(async fn)` — Action 패턴** — `isPending` 자동 관리
- **ref-as-prop** — `forwardRef` 래퍼 불필요 (React 19부터 `ref` 직접 prop)
---
## 파일 구조
```
src/components/VirtualTable/
├── types.ts
├── mockApi.ts
├── columns.tsx
└── VirtualTable.tsx ← 메인
```
---
## 1. `types.ts`
```typescript
export interface Employee {
id: number;
name: string;
email: string;
department: string;
role: string;
salary: number;
startDate: string;
status: "Active" | "Inactive" | "Pending";
location: string;
manager: string;
projects: string; // 줄바꿈 포함 → 행 높이 가변
notes: string; // 길이 가변 → 행 높이 가변
}
export interface SortConfig {
id: keyof Employee;
desc: boolean;
}
export type FilterMap = Partial<Record<keyof Employee, string>>;
export interface FetchParams {
sort: SortConfig | null;
filters: FilterMap;
}
export interface FetchResult {
rows: …채점 근거5요건 전부+α: TanStack Virtual/Table 선택근거+react-window 비교+measureElement 가변높이+sticky 헤더/첫컬럼+키보드(방향키·PgUp/Dn)+ARIA grid(role/aria-rowcount/sort/colindex)+서버 정렬·필터+디바운스300ms+다중선택 Shift범위(anchorRef)+AbortController+React19(use/useTransition/ref-as-prop). opus급 완성도
Gemini 3.5 FlashGoogle808060808078
React 19, Tailwind CSS v4, TypeScript 환경에서 요구사항을 모두 충족하는 고성능 가상 스크롤 테이블 컴포넌트 구현체입니다.
---
### 1. 기술 스택 및 라이브러리 선택 근거
1. **TanStack Table v8 (`@tanstack/react-table`)**
* **선택 이유:** Headless UI 라이브러리로서 마크업과 스타일링(Tailwind)에 대한 완전한 제어권을 제공합니다. 정렬, 컬럼 필터링, 다중 선택 기능이 내장되어 있어 복잡한 테이블 로직을 직접 구현할 필요가 없으며 성능이 매우 뛰어납니다.
2. **TanStack Virtual v3 (`@tanstack/react-virtual`)**
* **선택 이유:** **가변 행 높이(Dynamic Row Height)**를 가장 완벽하게 지원하는 라이브러리입니다. DOM 엘리먼트의 크기를 실시간으로 측정(`ResizeObserver` 기반)하여 36px~72px 사이의 가변 높이를 자연스럽게 렌더링합니다. react-window에 비해 현대적이고 Headless 방식이라 TanStack Table과의 궁합이 가장 좋습니다.
3. **React 19 신기능 활용**
* **`useTransition`:** 서버 fetch(정렬/필터) 시 UI 블로킹을 방지하고 `isPending` 상태를 통해 로딩 스피너를 자연스럽게 보여줍니다.
* **`useDeferredValue` / `use` 훅:** 비동기 데이터 로딩 및 디바운스된 필터 값의 지연 처리에 활용하여 성능을 최적화합니다.
---
### 2. 컴포넌트 구현 코드
먼저 필요한 패키지를 설치합니다.
```bash
npm install @tanstack/react-table @tanstack/react-virtual
```
#### `types.ts` (데이터 타입 정의)
```typescript
export interface DataRow {
id: string;
col1: string; // ID / Code
col2: string; // Name (가변 길이 텍스트)
col3: string; // Category
col4: number; // Value
col5: string; // Status
col6: string; // Date
col7: string; // Description (가변 길이 텍스트)
col8: string;
col9: string;
col10: string;
col11: string;
col12: string;
}
```
#### `mockData.ts` (1만 행 모의 데이터 생성 및 서버 Fetch 모사)
```typescript
import { DataRow } from './types';
// 1만개 데이터 생성 (가변 높이를 유도하기 위해 텍스트 길이를 다르게 설정)
export const generateMockData = (): DataRow[] => {
co…채점 근거전 요건 충족(measureElement 가변높이/sticky/ARIA/키보드/디바운스/shift). use()를 디바운스 지연처리에 활용한다고 약간 혼동(use는 promise/context용)
Nemotron 3 Ultra 550BNVIDIA608062758874
## 1️⃣ 라이브러리 선택 & 이유 | 기능 | 라이브러리 | 선택 이유 (한 줄) | |------|------------|-------------------| | **가상 스크롤 + 가변 행 높이** | **`@tanstack/react-virtual`** (v3) | *React‑19‑ready* (hooks만 사용, `useVirtualizer`가 `useSyncExternalStore` 기반이라 동시성 안전) + 행 높이 측정(`measureElement`) 기본 제공, `overscan`·`scrollToIndex` 등 풍부. | | **테이블 상태(정렬·필터·선택·페이지네이션 등)** | **`@tanstack/react-table`** (v8) | 헤드리스(UI‑agnostic)라 Tailwind 4 클래스만 입히면 되고, **서버 사이드 정렬/필터**를 `getCoreRowModel`·`getSortedRowModel`·`getFilteredRowModel` 없이 직접 `manual` 모드로 제어 가능. | | **키보드 네비 / ARIA grid** | **자체 구현** (TanStack Table의 `getRowProps` + `useEffect`‑free `useEventListener` 훅) | 별도 라이브러리 없이 `role="grid"` + `aria‑rowindex` 등을 직접 제어하면 번들 크기 최소화. | | **디바운스 fetch** | **`useDeferredValue` + `useTransition` (React 19)** | `useDeferredValue`로 입력 값을 지연시키고, `useTransition`로 fetch를 *비동기 액션*으로 처리 → 별도 `lodash.debounce` 불필요. | | **행 다중 선택 + Shift‑range** | **TanStack Table `getToggleAllRowsSelectedProps` / `getToggleRowSelectedProps`** + 커스텀 `onRowClick` | 내장 선택 상태(`rowSelection`)가 이미 **Set<string>** 로 관리돼 Shift‑range 로직만 얹으면 됨. | > **결론** – **`@tanstack/react-virtual` + `@tanstack/react-table`** 만으로 모든 요구사항을 커버할 수 있고, 둘 다 **React 19‑compatible (hooks‑only, no class components, `useSyncExternalStore`)** 이라 별도 polyfill 없이 동작합니다. --- ## 2️⃣ React 19 신기능 활용 포인트 | 신기능 | 사용처 | 왜 쓰는가 | |--------|--------|-----------| | **`use` 훅 (React 19 RC)** | `const data = use(fetchRows(params))` | 비동기 데이터를 *컴포넌트 렌더 중*에 바로 읽을 수 있게 해줌 → `useEffec…
채점 근거1만행 가상스크롤 그리드. 아키텍처(react-virtual+react-table v8·React19 use/useActionState/useOptimistic/useTransition·sticky header+첫컬럼·ARIA grid·키보드네비)는 7요구 체크리스트로 완전 커버 but ★프레임워크 API 다수 환각: useDeferredValue(params,{timeoutMs:300}) — timeoutMs 옵션 미존재(디바운스 날조) / createTable(...) — v8은 useReactTable(잘못된 API) / getHeaderProps·getHeaderGroupProps — v7 잔재(v8 제거) / use((async()=>...)()) 매 렌더 새 Promise(무한 suspense) / getCanSticky·enableSticky 가공 API. '60fps·메모리<30MB·axe통과' 미검증 단정. 구조s30·의도i20이 점수 방어하나 g(코드정확성) 실질 버그 다수→58대. B4 q4식 false-precision 재현(설득력 高·미실행).
DeepSeek V4 FlashDeepSeek808080708079
## 가상 스크롤 테이블 컴포넌트 설계 (React 19 + Tailwind 4 + TypeScript)
### 1. 라이브러리 선택과 근거
| 라이브러리 | 역할 | 선택 근거 |
|---|---|---|
| **@tanstack/react-table (v8)** | 테이블 상태 관리 (정렬, 필터, 선택, 키보드 네비, ARIA) | 정렬/필터/선택이 내장, headless(스타일 자유), TypeScript 완전 지원, React 19 호환 |
| **@tanstack/react-virtual (v3)** | 가상 스크롤 (행/열 모두 가능) | 불필요한 DOM 렌더링 최소화, 가변 높이 지원(`measureElement`), react-table과 동일 생태계로 통합 용이 |
| **lodash.debounce** | 필터 디바운스 (300ms) | 가벼움, 안정적, TypeScript 지원 |
| **Tailwind CSS v4** | 스타일링 (sticky, 선택, 포커스 등) | 유틸리티 기반, 반응형 클래스, `sticky`/`z-index` 제어 편리 |
TanStack Table + Virtual 조합은 대규모 데이터 테이블의 사실상 표준입니다. react-window는 가변 높이 처리가 까다로운 반면, react-virtual은 `measureElement` 콜백으로 동적 측정을 우아하게 지원합니다.
---
### 2. 컴포넌트 구조 개요
```
VirtualTable
├── Header (sticky top)
│ └── 각 컬럼 헤더 (정렬 버튼 + 필터 입력)
├── BodyContainer (overflow: auto, 가상 스크롤)
│ └── Virtualizer의 virtualItems.map()
│ └── Row (tr) with ref for height measurement
│ └── 각 Cell (첫 컬럼 sticky left)
└── (선택적) 푸터/선택 정보
```
---
### 3. 주요 구현 세부사항
#### 3.1 데이터 fetching, 정렬/필터 (서버 사이드 가정)
```tsx
// 커스텀 훅: useDataFetching
type FetchParams = {
sorting: SortingState;
columnFilters: ColumnFiltersState;
};
const useData = () => {
const [data, setData] = useState<Person[]>([]);
const [isLoading, setIsLoading] = useState(false);
const debouncedParamsRef = useRef<FetchParams>();
const debouncedSetParams = useMemo(
() => debounce((params: FetchParams) => {
debouncedParamsRef.current = params;
}, 300),…채점 근거전요건+TanStack+ARIA grid+React19 훅. 완전
Qwen 3.7 PlusAlibaba808280648279
요구사항을 완벽하게 충족하기 위해 **TanStack Table**과 **TanStack Virtual**을 조합하여 사용하는 것이 최선입니다.
### 📚 라이브러리 선택 근거
1. **`@tanstack/react-table` (v8)**:
- **이유**: "Headless" UI 라이브러리이므로, DOM 구조를 완전히 제어할 수 있습니다. 이는 ARIA `grid` 역할 부여, Sticky 컬럼/헤더 구현, 그리고 커스텀 키보드 네비게이션 로직을 삽입하는 데 필수적입니다. 정렬, 필터링, 다중 선택(Shift 포함) 로직을 이미 검증된 형태로 제공합니다.
2. **`@tanstack/react-virtual`**:
- **이유**: `react-window`보다 현대적이며, TanStack Table과의 통합이 원활합니다. 특히 **가변 Row Height(Requirement 1)** 를 위한 `measureElement` API를 공식적으로 지원하여, 콘텐츠 길이에 따라 36px~72px로 변하는 높이를 동적으로 계산하고 스크롤 위치를 보정할 수 있습니다.
### ⚛️ React 19 & Tailwind 4 적용 전략
- **React 19**: `useTransition`을 사용하여 필터링/정렬 시 UI 블로킹을 방지하고 부드러운 UX를 제공합니다. (Debounced fetch는 여전히 `AbortController`와 `setTimeout` 조합이 서버 과부하 방지 및 취소 처리에 가장 적합하므로 이를 유지하되, React 19의 동시성 기능과 결합합니다.)
- **Tailwind 4**: 네이티브 CSS 변수와 최신 유틸리티 클래스를 활용하여 Sticky 및 Z-index 충돌을 해결합니다.
---
### 💻 전체 구현 코드
```tsx
import React, { useState, useRef, useEffect, useTransition, useCallback } from 'react';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
SortingState,
ColumnDef,
RowSelectionState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
// ==========================================
// 1. Types & Mock Data Generation
// ==========================================
type DataRow = {
id: string;
col1: string; // Sticky column
col2: string;
col3: string;
col4: string;
col5: string;
col6: string;
…채점 근거가상 스크롤 테이블 — TanStack Table v8 + TanStack Virtual 선택 근거(headless·measureElement 가변행) 명시(react-window 비교). sticky 첫컬럼 meta 플래그 + ARIA columnheader·aria-sort + useTransition(React 19) + 키보드 네비 상태 + shift 범위선택 anchor ref. 가변행 36~72px measureElement 패턴 정확. 미세: useServerData 훅의 setTimeout-in-Promise 다소 장황 + 의존성 [] 라 필터/정렬 재트리거는 '확장 가능'으로 미완(주석 처리) + gridcell role/aria-rowindex 명시 약함.
시그니처“qwen3.7-plus, TanStack Table+Virtual(measureElement 가변행)+sticky+ARIA+useTransition+shift 범위선택 망라”
DeepSeek V4 ProDeepSeek808080708079
아래 코드는 요구사항을 모두 충족하는 가상 스크롤 데이터 그리드 컴포넌트의 예시입니다. **React 19**의 신기능(`use` 훅, `useDeferredValue`, `flushSync`)을 적극 활용했으며, **TanStack Table**(헤드리스 테이블 로직)과 **TanStack Virtual**(행 가상화 및 동적 높이 측정)을 조합했습니다. 테일윈드 4 스타일과 타입스크립트를 적용했습니다.
---
## 📦 라이브러리 선택 근거
- **TanStack Table (v8)**
헤드리스이고 프레임워크에 구애받지 않으며, 정렬·필터·다중 선택 등 테이블 핵심 상태 관리를 타입 안전하게 제공합니다. React 19와 완전히 호환되며, 행 모델과 컬럼 정의 API를 통해 가상화 라이브러리와 쉽게 결합할 수 있습니다.
- **TanStack Virtual (v3)**
TanStack Table과 자연스럽게 통합되는 공식 가상화 솔루션입니다. `measureElement`를 통해 가변 행 높이를 자동 측정하며, 동적 콘텐츠(36~72px)를 정확히 처리합니다. React 19를 공식 지원합니다.
- **대안**
`react-window` + `react-virtualized-auto-sizer`는 동적 높이 측정이 까다롭고, TanStack Table과의 조합을 위한 어댑터가 필요합니다. 유지보수와 개발 경험 측면에서 TanStack 생태계가 우수하다고 판단했습니다.
---
## 🧩 React 19 신기능 활용
| 기능 | 적용 지점 |
|------|------------|
| **`use` 훅** | 서버 데이터를 프로미스로 받아와 `Suspense` 내에서 `use(dataPromise)`로 직접 해제합니다. `useEffect` 없이 선언적 데이터 로딩을 구현합니다. |
| **`useDeferredValue`** | 컬럼 필터 상태를 지연시켜 300ms 디바운스를 대체합니다. 타이핑 중 렌더링 부하와 서버 요청을 줄입니다. |
| **`flushSync`** | 키보드 네비게이션 시 상태 업데이트 직후 DOM에 동기적으로 반영해 포커스를 즉시 이동시킵니다. `useEffect` 없이 포커스를 처리합니다. |
| **`useActionState`** | 직접 사용하지 않았으나, 필터 입력 폼과 서버 액션을 연동할 때 확장 가능하도록 설계했습니다 (주석으로 예시 포함). |
---
## 📋 전체 코드
```tsx
// VirtualTable.tsx
import React, {
useState,
useMemo,
useCallback,
useRef,
useDeferredValue,
Suspense,
} from "react";
import { flushSync } from "react-dom";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
ColumnDef…채점 근거18KB 전요건+TanStack+ARIA+React19+shift범위. 완주(q1과 달리 정상)
Mimo V2.5 ProXiaomi808080708079
# React 19 + Tailwind 4 가상 스크롤 테이블
## 1. 라이브러리 선정 근거
| 라이브러리 | 역할 | 선정 이유 |
|---|---|---|
| **@tanstack/react-virtual v3** | 가상 스크롤 엔진 | `measureElement` + `ResizeObserver`로 가변 행 높이 자동 측정. `react-window`의 `VariableSizeList`는 높이를 **미리** 알아야 하지만, 이 라이브러리는 렌더 후 측정 → 진짜 컨텐츠 기반 높이. ~3KB, React 19 ref-callback-clean-up 호환 |
| **TanStack Table — ❌ 미사용** | — | 서버 사이드 정렬/필터는 단순 `QueryParams → Promise` 파이프면 충분. 클라이언트 측 선택(Set)도 직접 구현 가능. TanStack Table 도입 시 row-model 추상화와 가상 스크롤 간 접착 코드가 오히려 복잡해짐 |
## 2. React 19 기능 활용 판단
| 기능 | 사용 | 이유 |
|---|---|---|
| `use(promise)` | ✅ | 렌더 중 프로미스 언래핑 + `<Suspense>` 결합으로 선언적 데이터 로딩 |
| `useTransition` | ✅ | 정렬·필터 변경 시 이전 UI 유지(`isPending`)하며 네트워크 fetch — 비차단 전환 |
| ref callback cleanup | ✅ | `ResizeObserver` 해제를 ref 콜백 반환값으로 처리 |
| `forwardRef` | ❌ | React 19에선 ref가 일반 prop — 불필요 |
| `useActionState` | ❌ | 폼 액션(Server Action) 전용. 읽기 전용 정렬·필터에는 `useTransition`이 적합 |
| `useOptimistic` | ❌ | 낙관적 뮤테이션(쓰기) 전용. 읽기 작업에서는 `isPending`으로 충분 |
| `useEffect` | ❌ 회피 | 데이터 fetching → `use()`, 디바운스 → ref+`setTimeout`, 측정 → ref-callback + `ResizeObserver` |
---
## 3. 전체 구현 코드
```tsx
// =============================================================
// VirtualTable.tsx
// React 19 + Tailwind 4 + @tanstack/react-virtual v3
// =============================================================
import {
useState,
useRef,
useCallback,
useTransition,
use,
Suspense,
useMemo,
Component,
type ReactNode,
type KeyboardEvent as ReactKB,
type MouseEvent a…채점 근거61.7K tok(64K 코앞) 완주. 5요건 충족+TanStack Virtual 선택근거(react-window VariableSizeList 사전높이 한계 비교)+★TanStack Table 의도적 미사용 근거(서버사이드 정렬/필터는 단순 파이프면 충분, Set 선택 직접구현)+React19 판단+measureElement 가변높이+sticky+ARIA grid+키보드(방향키·PgDn)+Shift범위(anchorIndex)+디바운스. 30K자 밀도(sonnet 동급, 과도 아님)
GPT-5.4 MiniOpenAI808080808080
아래 조합을 추천합니다.
- **TanStack Table**: 컬럼 정의, 정렬/필터 상태, 선택 상태를 headless하게 관리하기 좋음
- **TanStack Virtual**: `measureElement`로 **가변 row height** 처리 가능, overscan/스크롤 제어가 쉬움
- **왜 react-window가 아닌가?**
`react-window`는 고정 높이 리스트에 특히 강하고, variable height + sticky first column + ARIA grid + 서버 정렬/필터 + 범위 선택까지 같이 가면 커스텀 코드가 더 많아집니다.
이번 요구사항은 **“렌더링 가상화”보다 “그리드 상태/접근성/선택”이 더 복잡**해서 TanStack 조합이 더 적합합니다.
---
## React 19 신기능 사용 판단
- **Actions / `useActionState`**: 이번 케이스엔 **굳이 쓰지 않는 편이 낫습니다.**
- 이유: 필터 입력은 “폼 제출”이 아니라 **실시간 디바운스 검색**에 가깝고,
- 정렬/선택/키보드 이동까지 함께 얽혀 있어서 Action으로 묶으면 오히려 복잡해집니다.
- **`use()`**:
RSC(서버 컴포넌트)에서 초기 데이터 프리로드를 하고 싶다면 쓸 수 있지만,
이 컴포넌트 자체는 **강한 인터랙션이 있는 Client Component**로 두는 게 더 자연스럽습니다.
- 결론: **핵심 구현은 일반 훅 + TanStack으로 작성**하고, React 19 신기능은 “초기 데이터 스트리밍” 같은 상위 레이어에서 선택적으로 쓰는 게 좋습니다.
---
## 구현 코드
> `VirtualGridTable.tsx`
> (Next.js라면 파일 상단에 `'use client';` 추가)
```tsx
'use client';
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
ColumnDef,
ColumnFiltersState,
RowSelectionState,
SortingState,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
type Updater<T> = T | ((old: T) => T);
const applyUpdater = <T,>(updater: Updater<T>, old: T): T =>
typeof updater === 'function' ? (updater as (old: T) => T)(old) : updater;
function useDebouncedValue<T>(value: T, delay = 300) {
const [debounce…채점 근거전 요건+React19 판단 정확(Action 묶으면 복잡/use는 RSC용·이건 client comp). 결론 일반훅+TanStack
Kimi K2.6Moonshot808080808080
아래는 요구사항을 충족하는 **TanStack Table** + **TanStack Virtual** 기반의 완전한 예시입니다.
React 19의 `useTransition`을 활용해 필터/정렬 상태 전환을 논블로킹 처리하고, `useEffect` cleanup 없이 이벤트·타이머·DOM 측정을 모두 처리합니다.
---
## 1. 라이브러리 선택 근거
| 라이브러리 | 선택 이유 |
|---|---|
| **@tanstack/react-table** | 1만 행의 **정렬·필터·다중 선택·Shift 범위 선택** 상태를 헤드리스로 관리. 서버 사이드 모드(`manualSorting`/`manualFiltering`)를 내장 지원하므로 디바운스된 fetch와 자연스럽게 결합됨. |
| **@tanstack/react-virtual** | 가변 높이를 지원하는 `measureElement` API가 내장되어 있어 36~72px 가변 행 높이를 정확히 측정. 스크롤 컨테이너 기반이라 `sticky` header/first-column 레이아웃과 충돌 없이 결합 가능. |
> **react-window 등 미선택 이유**: 고정 높이 위주이며, 테이블 전용이 아니라 `sticky` 컬럼이나 복잡한 ARIA grid 역할을 직접 구현해야 하는 부담이 큼.
---
## 2. React 19 기능 활용 판단
| 기능 | 사용 여부 | 판단 근거 |
|---|---|---|
| `useTransition` | ✅ 사용 | 필터/정렬 변경 시 `startTransition`으로 데이터 교체. 로딩 인디케이터 없이 UI가 멈추지 않고, 이전 데이터가 유지되다가 새 데이터로 전환됨. |
| `use` (훅) | ❌ 미사용 | `use`는 **한 번 생성된 Promise를 읽는** 데 최적화되어 있음. 필터 입력마다 새 Promise를 만들고 300ms 디바운스를 걸어 Suspense를 트리거하면 로딩 플래시가 반복되어 UX가 오히려 저하됨. 따라서 전통적인 상태 기반 fetch가 더 적합. |
| `useEffect` cleanup | ❌ 회피 | 타이머는 `useRef`로, DOM 측정은 TanStack Virtual의 `measureRef`로, 이벤트는 React 합성 이벤트로 처리. 컴포넌트 레벨 `useEffect`를 전혀 사용하지 않음. |
---
## 3. 전체 코드
```tsx
// VirtualTable.tsx
import React, { useCallback, useMemo, useRef, useState, useTransition } from 'react';
import {
useReactTable,
getCoreRowModel,
ColumnDef,
SortingState,
ColumnFiltersState,
RowSelectionState,
Updater,
functionalUpdate,
flexRender,
} from '@tanstack/react-table';
import { u…채점 근거전 요건+React19 사용판단 표+react-window 미선택 이유(고정높이)+React18 cleanup 인지
GLM 5.1Z.ai808080808080
## 1. 라이브러리 선택 및 근거
가상 스크롤과 테이블 로직을 위해 **`@tanstack/react-virtual`**과 **`@tanstack/react-table`**을 선택했습니다.
* **`@tanstack/react-virtual` 선택 근거:**
* **가변 높이(Variable Size) 지원:** 요구사항인 36~72px의 가변 행 높이를 처리하기 위해 DOM 노드의 실제 렌더링 크기를 측정하여 캐싱하는 동적 측정(Dynamic Measurement) 기능이 훌륭합니다. `react-window`는 가변 높이 처리 시 예상 높이를 수동으로 관리해야 하거나 렌더링 깜빡임이 발생하기 쉽습니다.
* **Headless UI:** 스타일이나 DOM 구조에 종속되지 않아 Sticky Header/Column 같은 복잡한 CSS 레이아웃을 자유롭게 구성할 수 있습니다.
* **`@tanstack/react-table` 선택 근거:**
* **강력한 상태 관리:** 정렬(Sorting), 필터링(Filtering), 행 선택(Row Selection) 등 테이블의 복잡한 상태를 선언적으로 관리할 수 있습니다.
* **Shift 범위 선택:** `enableMultiRowSelection` 및 `enableRangeSelection` 옵션을 통해 Shift+Click 범위 선택 로직을 내장하고 있어 복잡한 선택 상태를 직접 구현할 필요가 없습니다.
* **Headless:** 마크업을 강제하지 않아 ARIA `grid` 롤과 시맨틱 DOM을 완벽하게 제어할 수 있습니다.
---
## 2. React 19 신기능 활용 및 18 호환 코드 회피
* **`useEffect` cleanup 회피 (Debounce):** React 18에서 흔히 쓰이던 `useEffect` 내 `setTimeout` / `clearTimeout` 패턴은 클린업 함수로 인해 복잡도가 증가합니다. 본 코드에서는 `useRef`를 활용한 유틸리티 훅으로 300ms 디바운스를 처리하여 불필요한 `useEffect`를 완전히 제거했습니다.
* **`useTransition` (Actions 개념 확장):** 서버 데이터 패칭(Fetch) 시 상태 업데이트로 인해 키보드 네비게이션이나 입력이 멈추는(Blocking) 현상을 방지하기 위해 `useTransition`을 적용했습니다. 필터 입력은 즉각적으로 반영하고, 1만 행의 데이터 렌더링은 낮은 우선순위로 처리합니다.
* **Ref as Prop:** `forwardRef` 없이 컴포넌트에 `ref`를 직접 전달하여 코드를 간결화했습니다.
---
## 3. 전체 코드 구현
```tsx
import React, { useMemo, useState, useRef, useCallback, useTransition } from 'react';
import {
useReactTable,
getCoreRowModel,
getSo…채점 근거전 요건+useTransition으로 useEffect 제거+Ref as Prop. 견고
Gemma 4 12BGoogle787680608077
React 19, Tailwind CSS 4, TypeScript 환경에서 1만 행의 가변 높이 데이터를 효율적으로 처리하기 위한 아키텍처와 구현 코드를 제안합니다.
### 1. 라이브러리 선택 근거
* **TanStack Table (v8):**
* **이유:** "Headless UI" 라이브러리로서 정렬(Sorting), 필터링(Filtering), 다중 선택(Selection), 키보드 네비게이션 로직을 추상화하는 데 최적입니다. 가상 스크롤이나 UI 스타일링에 종속되지 않으므로 Tailwind 4와의 궁합이 완벽합니다.
* **TanStack Virtual (v3):**
* **이유:** `react-window`는 고정 높이(Fixed Height) 처리에 최적화되어 있어 가변 높이(Dynamic Height) 처리가 까다롭습니다. `TanStack Virtual`은 `measureElement` 기능을 통해 렌더링된 요소의 실제 높이를 측정하여 가상 스크롤 위치를 계산하므로 요구사항 (1)에 가장 적합합니다.
### 2. React 19 활용 포인트
* **`use` Hook:** 데이터 페칭 시 `Suspense`와 결합하여 로딩 상태를 선언적으로 처리합니다.
* **`useActionState` (구 `useFormState`):** 필터/정렬 조건 변경 시 서버 액션을 트리거하고 상태를 관리하는 데 활용할 수 있습니다.
* **Ref 안정성:** React 19의 개선된 Ref 처리와 `use`를 통한 비동기 데이터 흐름을 활용합니다.
---
### 3. 구현 코드
이 코드는 핵심 로직을 포함한 구조화된 예시입니다.
```tsx
import React, { use, useState, useMemo, useRef, useEffect } from 'react';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
ColumnDef,
SortingState,
ColumnFiltersState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { clsx } from 'clsx'; // tailwind 4와 조합용 유틸
// --- Types ---
type RowData = {
id: number;
name: string;
description: string; // 가변 높이 유발 요소
category: string;
// ... 나머지 8개 컬럼
};
// --- Mock Data Fetching (React 19 use hook compatible) ---
const fetchTableData = async (filters: ColumnFiltersState, sorting: S…채점 근거React19 가상스크롤 테이블: ★라이브러리 선택 정확 — TanStack Table v8 + TanStack Virtual(react-window 고정높이 한계→measureElement 가변높이) 비교근거 명확 + sticky 헤더(top-0 z-20)·첫컬럼(left-0 z-10 shadow) + ARIA role grid/row/gridcell + React19(use훅·Suspense·useActionState) 인지 + useDeferredValue 디바운스. 단 ★키보드 핸들러가 `e.keyNext`(존재X 환각 API)+`/* ... */` 미구현 + Shift 범위선택이 코드 아닌 산문으로만(페르소나 명시 요건 미충족→i) + 헤더 JSX `</table>` 닫힘 불일치. 형제 26b(79) 직하.
Qwen 3.7 MaxAlibaba808080808080
# React 19 + Tailwind 4 + TypeScript 가상 스크롤 테이블
## 1. 라이브러리 선택 근거
| 후보 | 채택 | 이유 |
|---|---|---|
| **@tanstack/react-virtual v3** | ✅ 채택 | `measureElement`로 가변 행 높이를 **자동 측정**. 헤드리스라 sticky CSS와 충돌 없음. React 19 공식 호환. |
| **@tanstack/react-table v8** | ✅ 채택 | 서버사이드 정렬/필터/row selection을 선언적으로 관리. `manualSorting`, `manualFiltering` 플래그로 fetch와 연동. |
| react-virtuoso | ❌ | 가변 높이 자동측정은 강력하지만 sticky 컬럼과 통합이 까다롭고, React 19 호환성이 아직 불확실. |
| react-window / VariableSizeList | ❌ | 구형 API, React 19 대응 불투명, `itemSize`를 수동 계산해야 함. |
| ag-Grid / MUI DataGrid | ❌ | 무겁고, 요구사항(가변 높이+sticky+ARIA)을 위해 전체 프레임워크를 도입할 필요 없음. |
**결론:** TanStack Virtual(가상화) + TanStack Table(상태)의 **헤드리스 조합**이 최적. CSS는 모두 Tailwind 4로 제어.
## 2. React 19 신기능 활용 여부
| 기능 | 활용 | 판단 근거 |
|---|---|---|
| `use()` (Promise/Context) | ❌ | 이 컴포넌트는 클라이언트 컴포넌트. 서버에서 Promise를 넘겨받을 일이 없음. |
| `useActionState` | ❌ | 폼 액션용. 테이블 정렬/필터는 폼 시맨틱이 아님. |
| `useOptimistic` | ❌ | 정렬/필터는 낙관적 UI보다 정확한 서버 응답을 기다리는 게 자연스러움. |
| `useSyncExternalStore` | ❌ | TanStack이 내부적으로 이미 사용. |
| **`ref` as prop** | ✅ | React 19부터 `forwardRef` 없이 ref를 prop으로 전달 가능 → `measureElement` 연결 시 깔끔. |
| `useEffect` cleanup | ✅ 유지 | React 19에서도 Strict Mode double-invoke는 유지되므로 debounce cleanup은 필수. 회피 대상 아님. |
> 결론: **React 19의 "ref as prop"만 채택**, 나머지는 유스케이스에 맞지 않아 기존 훅(`useState`, `useEffect`, `useCallback`, `useMemo`) 사용.
## 3. 전체 코드
```tsx
// VirtualTable.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
typ…채점 근거최고 React19 판단: use/useActionState/useOptimistic 각각 ❌+거부근거(폼시맨틱 아님/정확성 우선). 부적합 인식이 가장 sharp
Grok 4.3xAI808080708079
**권장 조합: TanStack Table v8 + TanStack Virtual v3**
### 선택 근거
- **TanStack Table**: 테이블 상태(정렬·필터·선택)를 가장 성숙하게 관리. v8은 headless라 UI를 완전히 제어할 수 있음. React 19와도 완벽 호환.
- **TanStack Virtual**: `react-window` 대비 **가변 행 높이**를 공식적으로 잘 지원 (`measureElement`). 같은 팀이 관리하므로 Table과 궁합이 좋음.
- `react-window`는 가변 높이 지원이 미흡하고, `react-virtualized`는 유지보수가 거의 중단됨.
### React 19 활용 판단
- `use` 훅: 데이터 fetching에 `Suspense` + `use`를 사용할 수 있으나, **디바운스 fetch**가 필요한 본 요구사항에서는 기존 `useTransition` + `useState` 조합이 더 적합.
- Actions: 필터 입력에 `useActionState`를 적용 가능하나, 300ms 디바운스가 핵심이라 큰 이점 없음. → **기존 패턴 유지**하면서 React 19 타입만 활용.
### 구현 코드 (React 19 + TS + Tailwind 4)
```tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useMemo, useRef, useState, useTransition } from 'react';
type RowData = Record<string, any>;
// 예시 데이터 (10,000행)
const generateData = (count: number): RowData[] =>
Array.from({ length: count }, (_, i) => ({
id: i,
name: `Item ${i}`,
desc: `Description ${i}`.repeat(Math.random() > 0.5 ? 2 : 1),
// ... 12개 컬럼
}));
const columnHelper = createColumnHelper<RowData>();
const columns = [
columnHelper.accessor('id', { header: 'ID', size: 80 }),
columnHelper.accessor('name', { header: '이름', size: 200 }),
// ... 12개 컬럼 정의
];
export default function VirtualTable() {
const [data, setData] = useState(() => generateData(10000));
const [sorting, setSort…채점 근거전요건 충족, 6KB
Mistral Small 4Mistral807070707574
# React 19 + Tailwind 4 + TypeScript 가상 스크롤 테이블 컴포넌트
아래는 **1만 행 데이터(12개 컬럼)**를 효율적으로 렌더링하는 가상 스크롤 테이블 컴포넌트입니다. TanStack Table v8, React 19의 최신 기능, 그리고 Tailwind 4를 활용하여 성능과 접근성을 모두 고려했습니다.
---
## 📦 설치 패키지
```bash
npm install @tanstack/react-table @tanstack/react-virtual tailwind-merge react
# Tailwind CSS v4 설치 (프로젝트 separately 설정)
```
---
## 📁 파일 구조
```
src/
├── components/
│ └── VirtualScrollTable.tsx
├── hooks/
│ └── useDebounce.ts
├── types/
│ └── table.ts
└── App.tsx (사용 예시)
```
---
## 🔧 1. `types/table.ts` - 타입 정의
```typescript
// src/types/table.ts
export type DataItem = {
id: string | number;
[key: string]: any;
};
export type ColumnDef<T> = {
accessorKey: string;
header: string;
cell?: (info: any) => React.ReactNode;
enableSorting?: boolean;
enableColumnFilter?: boolean;
size?: number;
};
export type VirtualScrollTableProps<T extends { id: string | number }> = {
data: T[];
columns: ColumnDef<T>[];
onRowSelect?: (rows: T[]) => void;
selectedRows?: T[];
onSortingChange?: (sorting: SortingState) => void;
onColumnFilterChange?: (filters: ColumnFiltersState) => void;
loading?: boolean;
estimatedRowHeight?: number;
};
export type SortingState = Array<{
id: string;
desc: boolean;
}>;
export type ColumnFiltersState = Array<{
id: string;
value: any;
}>;
export type RowSelectionState = Record<string, boolean>;
```
---
## 🔧 2. `hooks/useDebounce.ts` - 디바운스 훅
```typescript
// src/hooks/useDebounce.ts
import { useEffe…채점 근거전요건 충족하나 22KB 길고 일부 산만
Step 3.7 FlashStepFun528855708872
### 1. 외부 라이브러리 선택 근거
가변 높이 가상 스크롤 + 정렬/필터/선택 등 복잡한 테이블 로직을 효율적으로 구현하기 위해 다음 라이브러리를 선택했습니다:
- **@tanstack/react-table v8**: 헤드리스 테이블 라이브러리로, 정렬·필터·다중 선택·Shift 범위 선택 등 요구사항의 모든 테이블 로직을 내장하고 있습니다. 스타일링을 자유롭게 적용할 수 있어 Tailwind와 완벽하게 연동됩니다.
- **@tanstack/react-virtual v3**: 가변 높이 가상 스크롤을 최적화하여 지원하는 라이브러리로, 1만 행 규모의 데이터도 부드럽게 렌더링할 수 있습니다. 오버스캔, 스크롤 위치 최적화 등을 자동으로 처리해줍니다.
- **Lodash 등 불필요한 의존성 제거**: 디바운스 등 필요한 기능은 직접 구현하여 번들 크기를 최적화했습니다.
---
### 2. React 19 신기능 활용 여부
요구사항에 맞춰 React 19의 신기능을 적극 활용했으며, React 18 호환 코드(명시적 useEffect cleanup 등)를 최소화했습니다:
- **서버 액션 (`'use server'`)**: 데이터 fetching 로직을 서버 사이드로 분리하여 보안성과 성능을 높였습니다.
- **`useActionState`**: 서버 액션의 로딩·에러·데이터 상태를 자동으로 관리하여, 기존 `useState` + `useEffect`로 작성하던 fetch 로직을 간소화했습니다.
- **`use` 훅**: 디바운스 로직에 활용하여 컴포넌트 언마운트 시 타이머가 자동으로 취소되도록 했습니다. 명시적인 cleanup 함수 작성이 불필요해졌습니다.
- **액션 기반 상태 관리**: 정렬·필터 변경 시 자동으로 데이터를 갱신하는 로직을 액션으로 통합하여 코드 가독성을 높였습니다.
---
### 3. 요구사항 구현 상세
| 요구사항 | 구현 방식 |
|---------|----------|
| 가변 행 높이 (36~72px) | `@tanstack/react-virtual`의 `measureElement`로 실제 렌더링된 행의 높이를 실시간 측정하여 정확한 스크롤 위치를 계산합니다. |
| Sticky 헤더 + 첫 컬럼 | Tailwind의 `sticky` 클래스를 사용하여 헤더(`top-0`, z-index 20)와 첫 컬럼(`left-0`, z-index 10)을 고정했습니다. 헤더와 첫 컬럼이 겹치는 모서리는 z-index 30으로 처리하여 가림 현상을 방지했습니다. |
| 키보드 네비 + ARIA grid | 방향키/PgUp/PgDn/Home/End 키로 행 이동을 지원하고, `role="grid"`·`aria-selected`·`aria-sort` 등 ARIA 속성을 적용하여 접근성을 확보했습니다. |
| 정렬·필터 + 디바운스 300ms | TanStack Table의 내장 정렬·필터 기능을 사용하고, 필터 입력에 300ms 디바운스를 적용하여 불필요한 요청을 줄였습니다. |
| 행 선택 + Shift 범위 선택 | 클릭 단일 선택, Ctrl+클릭 다중 선택 토글, Shift+클릭 범위 선택을 지원합니다. 전체 데이터의 ID 순서를 유지하여 정확한 범위 선택이 가능합니다. |
| 무한 스크롤 | 스크롤이 하단 20% 지점에 도달하면 자동으로 다음 페이지 데이터를 fetch합니다. |
---
### 4. 전체 코드
#### (1) 타입 정의 (`types/index.ts`)
```typescript
export interface Column<T> {
id: string;
header: string;
accessorKey: keyof T;
filterable?: boolean;
sortable?: boolean;
width?: number;
cell?: (info: { row: T; getValue: () => unknown }) => React.ReactNode;
}
export interface RowData {
id: string | number;
firstName: string;
lastName: string;
name: string;
department: string;
position: string;
email: string;
phone: string;
joinDate: string;
salary: number;
status: string;
address: string;
note: string;
}
export interface FilterState {
[columnId: string]: string;
}
```
#### (2) 디바운스 훅 (`hooks/useDebounce.ts`) - React 19 `use` 훅 활용
```typescript
import { use, useState } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
// use 훅은 컴포넌트 언마운트 시 Promise 구독을 자동 취소하므로 cleanup 불필요
const resolvedValue = use(
new Promise<T>((resolve) => setTimeout(() => resolve(value), delay))
);
if (resolvedValue !== debouncedValue) {
setDebouncedValue(resolvedValue);
}
return debouncedValue;
}
```
#### (3) 서버 fetch 액션 (`actions/fetchTableData.ts`)
```typescript
'use server';
const PAGE_SIZE = 50;
export interface RowData {
id: string | number;
firstName: string;
lastName: string;
name: string;
department: string;
position: string;
email: string;
phone: string;
joinDate: string;
salary: number;
status: string;
address: string;
note: string;
}
export interface FetchTableDataResult {
data: RowData[];
totalCount: number;
allRowIds: (string | number)[];
error?: string;
}
export async function fetchTableData(
prevState: FetchTableDataResult,
formData: FormData
): Promise<FetchTableDataResult> {
const mode = (formData.get('mode') as 'replace' | 'append') || 'replace';
const page = Number(formData.get('page')) || 1;
const sort = {
column: formData.get('sortColumn') as string | null,
direction: formData.get('sortDirection') as 'asc' | 'desc' | null,
};
const filters: Record<string, string> = {};
formData.forEach((value, key) => {
if (key.startsWith('filter_')) {
filters[key.replace('filter_', '')] = value as string;
}
});
try {
// 실제 환경에서는 여기서 DB/API를 호출합니다.
// 데모용 가상 데이터 생성
const allData = generateMockData(10000);
// 정렬 적용
let sortedData = [...allData];
if (sort.column && sort.direction) {
sortedData.sort((a, b) => {
const aVal = a[sort.column as keyof RowData];
const bVal = b[sort.column as keyof RowData];
if (aVal < bVal) return sort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sort.direction === 'asc' ? 1 : -1;
return 0;
});
}
// 필터 적용
let filteredData = sortedData;
Object.entries(filters).forEach(([columnId, value]) => {
if (value.trim()) {
filteredData = filteredData.filter((row) =>
String(row[columnId as keyof RowData]).toLowerCase().includes(value.toLowerCase())
);
}
});
const allRowIds = filteredData.map((row) => row.id);
const start = (page - 1) * PAGE_SIZE;
const paginatedData = filteredData.slice(start, start + PAGE_SIZE);
const newData = mode === 'append' ? [...prevState.data, ...paginatedData] : paginatedData;
return {
data: newData,
totalCount: filteredData.length,
allRowIds,
};
} catch (error) {
return {
...prevState,
error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
};
}
}
function generateMockData(count: number): RowData[] {
const firstNames = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
const lastNames = ['민수', '지영', '철수', '영희', '민지', '현우', '수빈', '준호', '예은', '시우'];
const departments = ['개발팀', '기획팀', '디자인팀', '마케팅팀', '영업팀'];
const positions = ['사원', '대리', '과장', '차장', '부장'];
const statuses = ['재직', '휴직', '퇴사'];
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
firstName: firstNames[Math.floor(Math.random() * firstNames.length)],
lastName: lastNames[Math.floor(Math.random() * lastNames.length)],
name: `${firstNames[Math.floor(Math.random() * firstNames.length)]}${lastNames[Math.floor(Math.random() * lastNames.length)]}`,
department: departments[Math.floor(Math.random() * departments.length)],
position: positions[Math.floor(Math.random() * positions.length)],
email: `user${i + 1}@example.com`,
phone: `010-${String(Math.floor(Math.random() * 10000)).padStart(4, '0')}-${String(Math.floor(Math.random() * 10000)).padStart(4, '0')}`,
joinDate: new Date(Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 365 * 5)).toISOString().split('T')[0],
salary: Math.floor(Math.random() * 50000000) + 30000000,
status: statuses[Math.floor(Math.random() * statuses.length)],
address: `서울시 강남구 테헤란로 ${Math.floor(Math.random() * 100)}`,
note: '가상 데이터입니다.',
}));
}
```
#### (4) 가상 스크롤 테이블 컴포넌트 (`components/VirtualTable.tsx`)
```tsx
'use client';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Column,
RowData,
FilterState,
} from '@/types';
import { fetchTableData, FetchTableDataResult } from '@/actions/fetchTableData';
import { useDebounce } from '@/hooks/useDebounce';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
SortingState,
} from '@tanstack/react-table';
import { useActionState } from 'react';
// 컬럼 정의
const columns: Column<RowData>[] = [
{ id: 'id', header: 'ID', accessorKey: 'id', width: 80, sortable: true },
{ id: 'name', header: '이름', accessorKey: 'name', sortable: true, filterable: true },
{ id: 'department', header: '부서', accessorKey: 'department', sortable: true, filterable: true },
{ id: 'position', header: '직급', accessorKey: 'position', sortable: true, filterable: true },
{ id: 'email', header: '이메일', accessorKey: 'email', filterable: true },
{ id: 'phone', header: '전화번호', accessorKey: 'phone', filterable: true },
{ id: 'joinDate', header: '입사일', accessorKey: 'joinDate', sortable: true },
{ id: 'salary', header: '연봉', accessorKey: 'salary', sortable: true, cell: ({ getValue }) => `${Number(getValue()).toLocaleString()}원` },
{ id: 'status', header: '상태', accessorKey: 'status', sortable: true, filterable: true },
{ id: 'address', header: '주소', accessorKey: 'address', filterable: true },
{ id: 'note', header: '비고', accessorKey: 'note' },
];
const PAGE_SIZE = 50;
export default function VirtualTable() {
// 상태 관리
const [sorting, setSorting] = useState<SortingState>([]);
const [filters, setFilters] = useState<FilterState>({});
const debouncedFilters = useDebounce(filters, 300);
const [selection, setSelection] = useState<{ selectedIds: Set<number | string>; lastSelectedId: number | string | null }>({
selectedIds: new Set(),
lastSelectedId: null,
});
const [focusedRowIndex, setFocusedRowIndex] = useState<number>(-1);
const [currentPage, setCurrentPage] = useState<number>(1);
const [allRowIds, setAllRowIds] = useState<(number | string)[]>([]);
const parentRef = useRef<HTMLDivElement>(null);
// React 19 useActionState로 fetch 상태 관리
const [fetchState, fetchAction, isFetching] = useActionState(
async (prevState: FetchTableDataResult, formData: FormData) => {
return await fetchTableData(prevState, formData);
},
{ data: [], totalCount: 0, allRowIds: [] }
);
// 데이터 로드 트리거
useEffect(() => {
const formData = new FormData();
formData.append('page', String(currentPage));
// 정렬·필터 변경 시 페이지 리셋 및 데이터 대체
const hasSortOrFilter = sorting.length > 0 || Object.values(debouncedFilters).some(v => v.trim());
if (hasSortOrFilter && currentPage !== 1) {
setCurrentPage(1);
return;
}
formData.append('mode', hasSortOrFilter ? 'replace' : 'append');
if (sorting.length > 0) {
formData.append('sortColumn', sorting[0].id);
formData.append('sortDirection', sorting[0].desc ? 'desc' : 'asc');
}
Object.entries(debouncedFilters).forEach(([key, value]) => {
formData.append(`filter_${key}`, value);
});
fetchAction(formData);
}, [debouncedFilters, sorting, currentPage, fetchAction]);
// 전체 ID 목록 동기화
useEffect(() => {
if (fetchState.allRowIds.length > 0) {
setAllRowIds(fetchState.allRowIds);
}
}, [fetchState.allRowIds]);
// TanStack Table 인스턴스 생성
const table = useReactTable({
data: fetchState.data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
state: { sorting },
});
// 가상 스크롤 설정
const rowVirtualizer = useVirtualizer({
count: fetchState.totalCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // 기본 추정 높이 (36~72px 범위)
overscan: 5, // 화면 외 5줄 미리 렌더링
measureElement: (el) => el.getBoundingClientRect().height,
});
// 스크롤 하단 감지로 무한 스크롤 구현
useEffect(() => {
const scrollEl = parentRef.current;
if (!scrollEl) return;
const handleScroll = () => {
const { scrollHeight, scrollTop, clientHeight } = scrollEl;
const totalPages = Math.ceil(fetchState.totalCount / PAGE_SIZE);
// 스크롤이 하단 20% 이하로 남으면 다음 페이지 로드
if (scrollHeight - scrollTop - clientHeight < scrollHeight * 0.2) {
if (currentPage < totalPages && !isFetching) {
setCurrentPage((prev) => prev + 1);
}
}
};
scrollEl.addEventListener('scroll', handleScroll);
return () => scrollEl.removeEventListener('scroll', handleScroll);
}, [currentPage, isFetching, fetchState.totalCount]);
// 정렬 토글 핸들러
const handleSort = (columnId: string) => {
setSorting((prev) => {
if (prev.length === 0 || prev[0].id !== columnId) {
return [{ id: columnId, desc: false }];
}
return prev[0].desc ? [] : [{ id: columnId, desc: true }];
});
};
// 필터 변경 핸들러
const handleFilterChange = (columnId: string, value: string) => {
setFilters((prev) => ({ ...prev, [columnId]: value }));
};
// 행 클릭 핸들러 (선택 + Shift 범위 선택)
const handleRowClick = useCallback(
(rowId: number | string, e: React.MouseEvent) => {
setSelection((prev) => {
const newSelected = new Set(prev.selectedIds);
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isShift && prev.lastSelectedId !== null) {
// Shift 범위 선택
const lastIdx = allRowIds.findIndex((id) => id === prev.lastSelectedId);
const currIdx = allRowIds.findIndex((id) => id === rowId);
if (lastIdx !== -1 && currIdx !== -1) {
const [start, end] = [Math.min(lastIdx, currIdx), Math.max(lastIdx, currIdx)];
for (let i = start; i <= end; i++) newSelected.add(allRowIds[i]);
}
} else if (isCtrl) {
// Ctrl 다중 선택 토글
newSelected.has(rowId) ? newSelected.delete(rowId) : newSelected.add(rowId);
} else {
// 단일 선택
newSelected.clear();
newSelected.add(rowId);
}
return { selectedIds: newSelected, lastSelectedId: rowId };
});
},
[allRowIds]
);
// 키보드 네비 핸들러
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const virtualItems = rowVirtualizer.getVirtualItems();
if (virtualItems.length === 0) return;
let newIndex = focusedRowIndex;
const totalPages = Math.ceil(fetchState.totalCount / PAGE_SIZE);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
newIndex = Math.min(focusedRowIndex + 1, fetchState.totalCount - 1);
break;
case 'ArrowUp':
e.preventDefault();
newIndex = Math.max(focusedRowIndex - 1, 0);
break;
case 'PageDown':
e.preventDefault();
if (currentPage < totalPages) setCurrentPage((prev) => prev + 1);
newIndex = Math.min(focusedRowIndex + PAGE_SIZE, fetchState.totalCount - 1);
rowVirtualizer.scrollToIndex(newIndex, { align: 'start' });
break;
case 'PageUp':
e.preventDefault();
if (currentPage > 1) setCurrentPage((prev) => prev - 1);
newIndex = Math.max(focusedRowIndex - PAGE_SIZE, 0);
rowVirtualizer.scrollToIndex(newIndex, { align: 'start' });
break;
case 'Home':
e.preventDefault();
newIndex = 0;
rowVirtualizer.scrollToIndex(0, { align: 'start' });
break;
case 'End':
e.preventDefault();
newIndex = fetchState.totalCount - 1;
rowVirtualizer.scrollToIndex(fetchState.totalCount - 1, { align: 'end' });
break;
default:
return;
}
if (newIndex !== focusedRowIndex) {
setFocusedRowIndex(newIndex);
rowVirtualizer.scrollToIndex(newIndex, { align: 'auto' });
}
},
[focusedRowIndex, rowVirtualizer, fetchState.totalCount, currentPage]
);
// 가상화 행 렌더링
const virtualRows = rowVirtualizer.getVirtualItems();
return (
<div className="w-full h-[600px] border border-gray-200 rounded-lg overflow-hidden">
<div
ref={parentRef}
className="h-full overflow-auto relative"
onKeyDown={handleKeyDown}
tabIndex={0}
role="grid"
aria-label="가상 스크롤 데이터 테이블"
>
{/* Sticky 헤더 */}
<div
className="sticky top-0 z-20 bg-gray-50 border-b border-gray-200 flex"
style={{ width: table.getTotalSize() }}
role="row"
>
{table.getHeaderGroups().map((headerGroup) => (
<div key={headerGroup.id} className="flex" role="row">
{headerGroup.headers.map((header) => (
<div
key={header.id}
className={`px-4 py-3 text-left text-sm font-medium text-gray-700 ${
header.column.getCanSort() ? 'cursor-pointer select-none' : ''
} ${header.index === 0 ? 'sticky left-0 z-30 bg-gray-50' : ''}`}
style={{ width: header.getSize() }}
role="columnheader"
aria-sort={
header.column.getIsSorted() === 'asc'
? 'ascending'
: header.column.getIsSorted() === 'desc'
? 'descending'
: 'none'
}
onClick={() => header.column.getCanSort() && handleSort(header.column.id)}
>
<div className="flex items-center gap-1">
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<span className="text-gray-400">
{header.column.getIsSorted() === 'asc' ? '↑' : header.column.getIsSorted() === 'desc' ? '↓' : '↕'}
</span>
)}
</div>
{/* 컬럼별 필터 입력 */}
{header.column.columnDef.filterable && (
<input
type="text"
placeholder="검색..."
value={filters[header.column.id] || ''}
onChange={(e) => handleFilterChange(header.column.id, e.target.value)}
className="mt-2 w-full px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
))}
</div>
))}
</div>
{/* 가상화 행 영역 */}
<div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{isFetching && (
<div className="absolute inset-0 bg-white/50 flex items-center justify-center z-40">
<div className="text-sm text-gray-500">로딩 중...</div>
</div>
)}
{virtualRows.map((virtualRow) => {
const globalIndex = virtualRow.index;
const isCurrentPage = globalIndex >= (currentPage - 1) * PAGE_SIZE && globalIndex < currentPage * PAGE_SIZE;
const row = isCurrentPage ? table.getRowModel().rows[globalIndex - (currentPage - 1) * PAGE_SIZE] : null;
const isSelected = row ? selection.selectedIds.has(row.original.id) : false;
const isFocused = focusedRowIndex === globalIndex;
return (
<div
key={virtualRow.key}
data-index={globalIndex}
ref={rowVirtualizer.measureElement}
className={`absolute top-0 left-0 w-full flex border-b border-gray-100 ${
isSelected ? 'bg-blue-50' : isFocused ? 'bg-gray-100' : 'hover:bg-gray-50'
} ${!isCurrentPage ? 'bg-gray-50/50' : ''}`}
style={{
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
role="row"
aria-selected={isSelected}
aria-rowindex={globalIndex + 1}
tabIndex={-1}
onClick={(e) => row && handleRowClick(row.original.id, e)}
>
{isCurrentPage && row ? (
row.getVisibleCells().map((cell) => (
<div
key={cell.id}
className={`px-4 py-2 text-sm text-gray-800 truncate ${
cell.column.index === 0 ? 'sticky left-0 z-10 bg-inherit' : ''
}`}
style={{ width: cell.column.getSize() }}
role="gridcell"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))
) : (
// 로딩 중이거나 미가져온 데이터 플레이스홀더
columns.map((col) => (
<div
key={col.id}
className={`px-4 py-2 text-sm text-gray-400 ${
col.id === columns[0].id ? 'sticky left-0 z-10 bg-gray-50/50' : ''
}`}
style={{ width: col.width || 150 }}
>
</div>
))
)}
</div>
);
})}
</div>
</div>
{/* 하단 정보 표시줄 */}
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50 text-sm text-gray-700 flex justify-between">
<span>
총 {fetchState.totalCount.toLocaleString()}개 행 | 선택됨: {selection.selectedIds.size.toLocaleString()}개
</span>
<span>{isFetching ? '로딩 중...' : '로딩 완료'}</span>
</div>
</div>
);
}
```
---
### 5. 실행 방법 및 주의사항
1. **필요한 패키지 설치**:
```bash
npm install @tanstack/react-table @tanstack/react-virtual
```
2. **Next.js 설정 (서버 액션 사용 시)**:
Next.js 14+를 사용하는 경우 `next.config.js`에 서버 액션을 허용하는 설정이 필요합니다:
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;
```
3. **성능 최적화**:
- 1만 행 데이터를 페이지 단위(50개)로 나누어 fetch하므로 초기 로딩 속도가 빠릅니다.
- 가상 스크롤로 실제 렌더링되는 행은 화면에 보이는 것만 처리하므로 메모리 사용량이 적습니다.
- 디바운스로 불필요한 API 요청을 300ms 단위로 줄였습니다.
4. **접근성**:
- 키보드만으로 테이블 조작이 가능합니다 (방향키 이동, PgUp/PgDn 페이지 이동, Home/End 첫/마지막 행 이동).
- 스크린 리더에서 행 선택 상태, 정렬 상태 등을 정상적으로 인식할 수 있습니다.
---
### 6. React 19 vs React 18 비교
| 기능 | React 19 구현 | React 18 대응 |
|------|--------------|--------------|
| 디바운스 | `use` 훅으로 자동 클린업 | `useEffect` + `setTimeout` + cleanup 함수 |
| fetch 상태 관리 | `useActionState`로 자동 관리 | `useState` + `useEffect`로 로딩/에러 상태 별도 관리 |
| 서버 로직 | 서버 액션으로 분리 | API 라우트 별도 생성 |
| 클린업 | `use` 훅 자동 처리 | 명시적 `useEffect` cleanup 함수 작성 필요 |
현재 코드는 React 19 환경에 최적화되어 있으며, React 18 지원이 필요한 경우 위 표의 대응 방식을 적용하면 됩니다.채점 근거TanStack table+virtual로 가변높이·sticky 헤더/첫컬럼·키보드 네비·ARIA grid·정렬/필터 디바운스·Shift 범위선택·무한스크롤 전 요구 커버. 단 ★`useDebounce`가 렌더마다 `use(new Promise(setTimeout))` 생성=새 promise 무한 re-suspend 루프(React 19 use 훅 근본 오용, 'use는 언마운트시 자동취소' 주장 날조) + 가상화 count=totalCount인데 현재 페이지만 로드해 globalIndex 매핑 fragile.
시그니처“step-3-7-flash는 디바운스를 `use(new Promise(r=>setTimeout(r,delay)))`로 구현 — render마다 새 Promise를 만들어 use에 넘기면 영구 suspend 루프에 빠지는 React 19 use 훅 오용”
Qwen 3.6 27BAlibaba807070707574
# React 19 가상 스크롤 테이블
## 라이브러리 선택 근거
| 라이브러리 | 선택 이유 |
|---|---|
| **@tanstack/react-table v8** | 헤드리스 테이블 엔진. 정렬·필터·선택·그룹링 등 모든 로직을 훅으로 제공. React 19 호환. 렌더링 제어권이 100% 개발자에게 있음 → ARIA/키보드 네비 커스터마이징에 최적 |
| **@tanstack/react-virtual** | `react-window`의 후속. `estimateSize` + 측정 콜백으로 **가변 행 높이**를 네이티브 지원. TanStack Table과 시너지. v8에서 `VariableSizeVirtualizer`가 안정화됨 |
| **외부 라이브러리 안 쓰는 부분** | 키보드 네비·ARIA·선택 로직은 TanStack Table이 부분만 지원하므로, **순수 React + DOM API**로 직접 구현. 불필요한 의존성 증가 방지 |
## React 19 신기능 활용 판단
| 기능 | 활용 여부 | 판단 근거 |
|---|---|---|
| `use()` 훅 | ❌ 제한적 | `use()`는 Suspense 경계에서만 동작. 디바운스 입력 + 조건부 fetch 패턴과 충돌. 대신 `useTransition` + `useRef` 기반 커스텀 훅 사용 |
| Actions 패턴 | ⚠️ 부분 활용 | 폼 제출용 Actions는 테이블과 무관. 대신 `useFormStatus`는 사용하지 않음 |
| `useOptimistic` | ✅ 활용 | 행 선택 시 서버 동기화 전 낙관적 UI 업데이트에 사용 |
| `useActionState` | ❌ | 폼 상태용. 테이블 데이터 fetch에는 부적합 |
| `useEffect` cleanup | ✅ 유지 | React 19에서도 `useEffect`는 여전히 유효. 키보드 리스너/디바운스 타이머 cleanup에 필수 |
---
## 전체 코드
```tsx
// ============================================================
// types.ts — 데이터 형식 정의
// ============================================================
export type SortDirection = 'asc' | 'desc' | null;
export interface ColumnDef<TData> {
accessorKey: keyof TData & string;
header: string;
filterable?: boolean;
sortable?: boolean;
cell?: (value: TData[keyof TData], row: TData) => React.ReactNode;
}
export interface RowData {
id: string | number;
[key: string]: unknown;
}
export interface TableStat…채점 근거전요건 충족하나 31KB 과도
Gemini 3.1 Flash LiteGoogle808080708079
요구하신 조건은 대규모 데이터 처리와 복잡한 UI 인터랙션을 포함하므로, 바닥부터 구현하기보다는 **TanStack Table(Logic)**과 **TanStack Virtual(Virtualization)**을 조합하는 것이 가장 안정적이고 성능 최적화가 보장됩니다.
### 1. 라이브러리 선정 근거
* **TanStack Table (v8):** 정렬, 필터링, 선택(Selection) 로직은 상태 관리가 매우 복잡합니다. 이를 직접 구현하면 엣지 케이스(Shift 선택 등)에서 버그가 발생하기 쉽습니다. headless UI 라이브러리 중 가장 성숙하고 TypeScript 지원이 완벽합니다.
* **TanStack Virtual:** 가변 높이(Variable height) 가상 스크롤을 가장 효율적으로 처리합니다. `estimateSize`와 `measureElement`를 통해 36~72px 사이의 가변 높이를 완벽하게 추적합니다.
* **React 19 활용:** `use` 훅을 활용한 데이터 로딩과 `useDeferredValue`를 통한 필터링 디바운싱을 구현하여 별도의 라이브러리 없이 리액트 네이티브한 방식으로 성능을 최적화합니다.
---
### 2. 구현 코드 (핵심 로직)
```tsx
// Table.tsx
import { use, useMemo, useState, useDeferredValue } from 'react';
import { useReactTable, getCoreRowModel, flexRender, getSortedRowModel } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
// 1. 데이터 Fetch (React 19 'use' hook 사용)
function fetchData(params: any): Promise<any[]> {
// 서버 fetch 로직
return fetch(`/api/data?${new URLSearchParams(params)}`).then(res => res.json());
}
export default function VirtualTable({ promise }) {
const data = use(promise); // React 19 use hook
const [columnFilters, setColumnFilters] = useState([]);
const deferredFilters = useDeferredValue(columnFilters); // 300ms 디바운스 효과 대체
// 2. TanStack Table 설정
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
// ... selection…채점 근거TanStack Virtual+가변행+sticky+ARIA grid+React19+shift범위 전요건 충족, 4.3KB 간결
Gemma 4 26B A4BGoogle808080708079
이 요구사항은 매우 높은 수준의 성능과 UX를 요구합니다. 특히 **가변 높이(Variable Height) 가상 스크롤**은 구현 난이도가 가장 높은 부분입니다.
### 1. 라이브러리 선정 및 근거
* **TanStack Table (v8):** Headless UI 라이브러리입니다. 정렬, 필터링, 선택 로직(Row Selection)을 직접 구현하면 버그가 발생하기 쉽고 복잡합니다. TanStack Table은 상태 관리와 로직을 담당하고, UI는 직접 제어할 수 있어 **Sticky Header/Column 구현에 최적**입니다.
* **@tanstack/react-virtual:** `react-window`는 고정 높이에 최적화되어 있어 가변 높이 구현이 매우 까다롭습니다. 반면 `react-virtual`은 **Dynamic Measurement(동적 측정)**를 지원하여 컨텐츠에 따라 변하는 행 높이를 효율적으로 계산합니다.
* **Lucide React:** 아이콘용.
### 2. React 19 활용 전략
* **`use` API:** 서버 데이터 페칭 시 `Suspense`와 결합하여 데이터를 읽어올 때 사용합니다.
* **`useActionState` (또는 `useTransition`):** 정렬/필터링 시 발생하는 비동기 상태 업데이트를 처리하여 UI 블로킹을 방지합니다.
* **Ref Cleanup:** React 19에서는 `ref`를 cleanup 함수로 반환할 수 있으므로, DOM 측정 시 더 깔끔한 로직이 가능합니다.
---
### 3. 구현 코드 (핵심 구조)
전체 코드는 매우 길어지므로, 핵심 로직이 담긴 컴포넌트 구조로 작성합니다.
```tsx
import React, { useMemo, useRef, useState, useCallback, useTransition, Suspense } from 'react';
import {
useReactTable,
getCoreRowModel,
flexRender,
ColumnDef,
SortingState,
ColumnFiltersState,
RowSelectionState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ArrowUpDown, ChevronDown, ChevronUp } from 'lucide-react';
// --- Types ---
interface DataItem {
id: number;
name: string;
status: string;
value: number;
[key: string]: any;
}
// --- Mock API (Server Fetch Simulation) ---
const fetchData = async (filters: any, sorting: any): Promise<DataItem[]> => …채점 근거전요건+TanStack+ARIA+React19. 완전
Qwen 3.6 35B A3BAlibaba807070707073
# 가상 스크롤 테이블 컴포넌트 — React 19 + Tailwind 4 + TypeScript
## 📦 라이브러리 선택 근거
| 라이브러리 | 선택 근거 |
|---|---|
| **`@tanstack/react-virtual`** | 가변 높이 행의 `sizeGetter` API를 공식 지원하며, `@tanstack/react-table`과 통합이 원활함. React 19 호환성 확인 완료. `react-window`는 고정 높이만 지원하므로 부적합. |
| **`@tanstack/react-table`** | 정렬·필터·선택 로직을 테이블 렌더링과 분리. 커스텀 DOM 렌더링(가상 스크롤 + sticky)과 잘 어울림. |
> `react-window`는 고정 높이 행만 지원하므로 **가변 높이(36~72px) 요구사항에 부적합**하여 제외.
## React 19 신기능 활용 여부 판단
| React 19 기능 | 이 컴포넌트에서의 활용 | 판단 |
|---|---|---|
| `use` 훅 | Promise 읽기용. 클라이언트 컴포넌트에서 서버 데이터 페칭에는 `useEffect`가 더 적합. | 미활용 |
| `useActionState` | 폼 액션 상태 관리용. 테이블 정렬/필터는 별도 상태 관리. | 미활용 |
| `useOptimistic` | 낙관적 업데이트용. 테이블 행 선택은 즉시 반영되므로 필요 없음. | 미활용 |
| `useEffect` cleanup | React 19에서도 여전히 지원되며, 가상 스크롤 리사이즈 리스너 정리 등에 사용. | **활용** |
| `useCallback` / `useMemo` | 성능 최적화를 위해 계속 사용. | **활용** |
> **결론**: 이 컴포넌트는 클라이언트 렌더링 중심의 UI 컴포넌트이므로 React 19의 신기능(`use`, Actions 등)이 크게 필요하지 않음. React 19 호환성을 유지하면서 기존 훅 패턴을 사용하는 것이 적절함.
---
## 전체 코드
```tsx
// ===========================
// types.ts
// ===========================
export interface TableRowData {
id: number;
name: string;
email: string;
department: string;
role: string;
status: "active" | "inactive" | "pending";
createdAt: string;
salary: number;
phone: string;
location: string;
manager: string;
tags: string[];
}
export type SortDirection = "asc" | "desc" | null;
export interface ColumnSort {
id: keyof TableRowData;
desc: boolean;
}
export interfa…채점 근거전요건 충족하나 34KB 과도(verbosity)→calibration·구조 효율 감점
Solar Pro 3Upstage507040408061
아래는 **React 19**, **Tailwind CSS 4**, **TypeScript** 로 구현한 **가상 스크롤 테이블** 예시입니다.
외부 라이브러리는 **react‑window**(가상 스크롤)와 **@tanstack/table‑core**(컬럼·정렬·필터·ARIA) 를 사용했으며, 각각의 선택 이유와 React 19 신기능을 활용한 코드를 함께 설명합니다.
---
## 1️⃣ 왜 `react-window` 와 `@tanstack/table-core` 를 선택했나요?
| 라이브러리 | 핵심 역할 | 선택 이유 |
|------------|----------|-----------|
| **react‑window** | `FixedSizeList` / `VariableSizeList` 로 DOM‑노드 수를 최소화 | 가상 스크롤의 가장 가볍고 성능이 검증된 구현체. `VariableSizeList` 로 가변 행 높이(36 ~ 72 px)를 자연스럽게 지원. |
| **@tanstack/table‑core** | 데이터·컬럼 관리, 정렬·필터, ARIA‑grid, 키보드 네비게이션, 다중 선택 | TanStack Table 은 **React‑agnostic** 이면서도 React 19 전용 `useTable` 훅을 제공. 복잡한 그리드 로직을 직접 구현할 필요 없이 선언형으로 사용 가능. 또한 ARIA‑grid, `RowSelection` 등 접근성 기능을 기본 제공. |
---
## 2️⃣ React 19 신기능 활용 여부
| React 19 기능 | 사용 여부 | 설명 |
|----------------|----------|------|
| `use` 훅 (React 19‑beta) | ✅ 사용 | `useTable`·`useRowVirtualization` 등 TanStack Table이 제공하는 `use` 훅을 그대로 활용. `use` 훅은 내부적으로 `useEffect`·`useMemo` 를 적절히 묶어 주어 코드 가독성을 높여 줍니다. |
| **Actions** (React 19‑stable) | ✅ 사용 | `useRowSelection` 에서 제공하는 `selectedRowIds` 를 `Actions` 로 선언하고, `toggleRowSelection` 을 `useAction` 으로 호출해 상태 변화를 선언형으로 처리. |
| **useTransition** / **useDeferredValue** (React 18‑호환) | ❌ 사용 안 함 (필요 시 대체 가능) | 현재 구현은 `debounce` 로 서버‑fetch 호출을 제어하고, 가상 스크롤 자체는 `VariableSizeList` 로 충분히 최적화됩니다. 필요 시 `useTransition` 으로 UI‑blocking을 완화할 수 있습니다. |
| **useSyncExternalStore** (React 18‑호환) | ❌ 사용 안 함 (TanStack Table 내부에서 사용) | TanStack Table 은 내부적으로 스토어를 관리하므로 별도 구현이 불필요합니다. |
> **정리** – React 19 의 `use` 훅과 `Actions` 를 활용해 선언형 상태·액션 흐름을 구현했으며, `useEffect`·`useLayoutEffect` 등은 직접 쓰지 않았습니다. React 18 호환 코드도 그대로 동작합니다.
---
## 3️⃣ 프로젝트 셋업 (필요한 패키지)
```bash
# npm
npm i react-window@latest @tanstack/table-core@latest @tanstack/table-react@latest
# Tailwind CSS (v4) – 이미 프로젝트에 적용돼 있다고 가정
npm i -D typescript @types/react
```
> `react-window` 와 `@tanstack/table-react` 은 현재 **React 18** 호환이지만, React 19에서도 문제 없이 동작합니다.
---
## 4️⃣ 코드 전체
```tsx
/* src/components/VirtualScrollTable.tsx */
import {
VariableSizeList,
ListChildComponentProps,
VariableSizeListProps,
} from 'react-window';
import type {
ColumnDef,
Row,
ColumnSizing,
ColumnMeta,
} from '@tanstack/table-core';
import {
useTable,
UseRowVirtualization,
RowSelection,
RowSelectionProps,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
} from '@tanstack/table-react';
import { useDebouncedCallback } from 'use-debounce';
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import type { FC, ReactNode } from 'react';
type RowData = Record<string, unknown>;
interface VirtualScrollTableProps {
/** 서버에서 받아온 전체 데이터 (클라이언트에 전체를 로드해도 메모리 부담이 없음) */
rows: RowData[];
/** 컬럼 정의 (고정·가시·가시‑width 등) */
columns: Column<RowData>[];
/** 컬럼별 정렬 옵션 */
getSortByToggleFn: (columnId: string) => (desc?: boolean) => void;
/** 컬럼별 필터 UI (컴포넌트) */
getFilterFn: (columnId: string) => (value: unknown) => (row: RowData) => boolean;
/** 서버 fetch 함수 – 디바운스 300ms 적용 */
fetchRows: (page: number, pageSize: number, sort: unknown, filters: unknown) => Promise<{ rows: RowData[]; pageCount: number }>;
/** 데이터가 없을 때 보여줄 UI */
emptyMessage?: ReactNode;
/** sticky header 를 위한 CSS 클래스 */
stickyHeaderClass?: string;
}
/**
* TanStack Table 컬럼 정의 타입
*/
type Column<TData = RowData> = ColumnDef<TData>;
/**
* 가상 스크롤된 테이블 컴포넌트
*/
export const VirtualScrollTable: FC<VirtualScrollTableProps> = ({
rows,
columns,
getSortByToggleFn,
getFilterFn,
fetchRows,
emptyMessage = 'No data',
stickyHeaderClass = '',
}) => {
/** ---------- 1️⃣ ARIA grid 역할 ---------- */
const tableRef = useRef<HTMLDivElement>(null);
const grid = useMemo(() => ({
role: 'grid',
ariaRowCount: rows.length,
ariaColumnCount: columns.length,
ariaLabel: 'Data grid',
}), [rows.length, columns.length]);
/** ---------- 2️⃣ TanStack Table ---------- */
const {
getHeaderGroups,
getRowModel,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
setColumnVisibility,
setRowSelection,
rows: tableRows,
...tableState
} = useTable(
{
columns,
data: rows,
// ① 정렬·필터·가상화·ARIA
getCoreRowModel, // 기본 row model (row height 계산용)
getRowVirtualizer: useRowVirtualization(),
getFilteredRowModel,
getSortedRowModel,
// ② sticky 헤더 & 첫 컬럼
getRowHeight: (row) => row._virtualSize ?? 48, // 48px을 기본값으로 (36~72 사이)
// ③ 다중 선택
rowSelection: useRowSelection<RowData>({
mode: 'multiple',
selectAll: true,
}),
},
useActions()
);
/** ---------- 3️⃣ 행 높이 계산용 Virtualizer ---------- */
const rowVirtualizer = useMemo(() => {
// rows 가 고정값이라고 가정하고, 가변 높이를 미리 측정
const heights = rows.map((r) => (r._virtualSize ?? 48) as number);
return {
getItemSize: (index: number) => heights[index],
};
}, [rows]);
const rowVirtualizerRef = useRef(rowVirtualizer);
useEffect(() => {
// rows 가 바뀔 때 virtualizer 를 재생성 (React 19 에서는 Actions 로도 가능)
rowVirtualizerRef.current = rowVirtualizer;
}, [rowVirtualizer]);
/** ---------- 4️⃣ 서버 fetch (디바운스) ---------- */
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(100); // 가상 스크롤 예시용 기본 페이지 크기
const [sorting, setSorting] = useState<unknown>([]);
const [filters, setFilters] = useState<unknown>({});
const fetch = useDebouncedCallback(
async (newSort: unknown, newFilters: unknown) => {
try {
const { rows: fetched, pageCount } = await fetchRows(page, pageSize, newSort, newFilters);
// TanStack Table 은 data 를 직접 교체하지 않으므로 `rows` 를 새로 전달
// (useRowVirtualization 은 내부에서 데이터 변화를 감지)
tableRows.setData(fetched);
setPage(page);
// 페이지가 바뀌면 페이지 크기를 재조정 (예: pageSize 자동 조정)
setPageSize(pageCount * pageSize); // 충분히 큰 값
} catch (e) {
console.error(e);
}
},
300 // ms
);
// 정렬·필터 상태 변화를 구독하고 fetch 를 트리거
useEffect(() => {
if (sorting.length === 0 && Object.keys(filters).length === 0) return;
fetch(sortByToggleFn, filters);
}, [sorting, filters, fetch]);
/** ---------- 5️⃣ 키보드 네비게이션 (키다운) ---------- */
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
const { key, target } = e;
const rows = getRowModel().rows;
const rowCount = rows.length;
const visibleRows = getRowVirtualizer().getVisibleRange()?.start ?? 0;
// 현재 포커스된 셀 위치 계산 (간단 구현)
// 실제 프로젝트에서는 `ref` 로 현재 셀을 추적하거나 `tabIndex` 를 조정
if (key === 'ArrowUp') {
e.preventDefault();
const newIdx = Math.max(0, visibleRows - 1);
// 스크롤 리스트에게 인덱스 이동 요청
rowVirtualizerRef.current.scrollToIndex(newIdx);
} else if (key === 'ArrowDown') {
e.preventDefault();
const newIdx = Math.min(rowCount - 1, visibleRows + 1);
rowVirtualizerRef.current.scrollToIndex(newIdx);
} else if (key === 'PageUp') {
e.preventDefault();
rowVirtualizerRef.current.scrollBy({
index: Math.max(0, visibleRows - pageSize),
scrollOffset: rowVirtualizerRef.current.getTotalSize(),
});
} else if (key === 'PageDown') {
e.preventDefault();
rowVirtualizerRef.current.scrollBy({
index: Math.min(rowCount - 1, visibleRows + pageSize),
scrollOffset: rowVirtualizerRef.current.getTotalSize(),
});
}
},
[pageSize, rowCount, visibleRows]
);
// 포커스가 테이블 내부일 때만 키다운을 받음
useEffect(() => {
const el = tableRef.current;
if (!el) return;
el.addEventListener('keydown', onKeyDown);
return () => el.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
/** ---------- 6️⃣ sticky header (Tailwind) ---------- */
const headerStyle = useMemo(() => ({
position: 'sticky',
top: 0,
zIndex: 1,
background: 'bg-white',
...stickyHeaderClass,
}), [stickyHeaderClass]);
/** ---------- 7️⃣ sticky 첫 컬럼 ---------- */
const firstColumnStyle = useMemo(() => ({
position: 'sticky',
left: 0,
zIndex: 0,
background: 'bg-gray-50',
// 고정 컬럼에 맞게 최소 높이 지정
minHeight: '48px',
}), []);
/** ---------- 8️⃣ JSX ---------- */
return (
<div
ref={tableRef}
className="relative h-[600px] w-full overflow-hidden"
{...grid}
>
{/* 헤더 그룹 (sticky) */}
<div
className={`${headerStyle} border-b border-gray-200`}
style={{ height: '48px' }} // 헤더 고정 높이
>
{getHeaderGroups().map((headerGroup) => (
<div
key={headerGroup.id}
className="flex items-center px-2 text-sm font-medium text-left"
>
{headerGroup.headers.map((header) => (
<div
key={header.id}
className="flex justify-between overflow-hidden whitespace-nowrap"
>
{header.renderHeader()}
{/* 정렬 UI */}
{header.column.getCanSort() && (
<button
className="h-full px-1"
onClick={() => header.column.toggleSort()}
>
{header.column.getIsSorted() ? (
header.column.getIsSortedDesc() ? '▲' : '▼'
) : (
'🔽'
)}
</button>
)}
</div>
))}
</div>
))}
</div>
{/* 가변 크기 리스트 */}
<VariableSizeList
height={600 - 48} // 헤더 높이 제외
width="100%"
itemCount={rows.length}
itemData={{
rows,
tableRows,
rowVirtualizer: rowVirtualizerRef.current,
}}
// React 19 에서는 `use` 훅이 내부적으로 `ref` 를 관리해 주므로
// 별도 cleanup 필요 없음
{...useRowVirtualization()}
>
{(listProps, style) => (
<div
ref={listProps.measureRef}
style={style}
className="overflow-auto"
>
{listProps.children.map((child: ListChildComponentProps) => {
const { index, style: itemStyle, data } = child;
const row = data.rows[index];
const virtualSize = row._virtualSize ?? 48; // TanStack Table 의 virtualSize
// sticky 첫 컬럼
const firstColStyle: React.CSSProperties = {
...firstColumnStyle,
width: '60px', // 첫 컬럼 고정 너비(예시)
height: virtualSize,
};
const cellProps = {
row,
tableRows,
rowVirtualizer: rowVirtualizerRef.current,
style: itemStyle,
firstColStyle,
headerStyle,
tableRef,
setPage,
page,
pageSize,
rowHeight: virtualSize,
} as const;
return (
<RowComponent
key={index}
{...cellProps}
rowIndex={index}
rowData={row}
/>
);
})}
</div>
)}
</VariableSizeList>
{/* 페이지네이션 UI (옵션) */}
{rows.length === 0 && (
<div className="h-8 text-center py-2 text-sm text-gray-500">{emptyMessage}</div>
)}
</div>
);
};
/* ---------- 9️⃣ RowComponent (가상 스크롤된 행) ---------- */
type RowProps = {
rowIndex: number;
rowData: Row;
style: React.CSSProperties;
firstColStyle: React.CSSProperties;
headerStyle: React.CSSProperties;
tableRef: React.RefObject<HTMLDivElement>;
setPage: (p: number) => void;
page: number;
pageSize: number;
rowHeight: number;
};
const RowComponent: FC<RowProps> = ({
rowData,
style,
firstColStyle,
rowIndex,
tableRef,
setPage,
page,
pageSize,
rowHeight,
}) => {
const { rowSelection, selectedRowIds } = useTable(
{
columns: [], // 여기서는 사용 안 함
data: [],
rowSelection: useRowSelection<Row>(),
},
useActions()
);
// 다중 선택 시 Shift‑range 선택 로직 (간단 구현)
const shiftSelection = (e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
const { pageX, shiftKey } = e;
// 실제로는 클릭·포커스를 추적해야 하지만 여기서는 예시만
// 선택된 행은 rowSelection.toggleSelection(row.id) 로 처리
// (row._rowId 가 존재한다고 가정)
if (shiftKey) {
// shift 로 마지막 선택 행 기준 범위 선택 구현 …
}
};
return (
<div
ref={tableRef} // focus 관리가 필요하면 여기서 관리
className="flex items-center bg-white"
style={style}
onClick={() => {
if (rowSelection.isSelected(rowData)) {
rowSelection.toggleSelection(rowData);
} else {
rowSelection.selectRow(rowData);
}
}}
onKeyDown={shiftSelection}
tabIndex={0}
>
{/* 첫 컬럼 (sticky) */}
<div className="sticky -left-0 p-2 border-r border-gray-200" style={firstColStyle}>
{/* 체크박스 */}
<input
type="checkbox"
checked={selectedRowIds.has(rowData._rowId as string)}
onChange={(e) => e.target.checked && rowSelection.toggleSelection(rowData)}
className="h-4 w-4 text-blue-600"
/>
</div>
{/* 실제 셀들 */}
<div className="flex flex-1 flex-col overflow-hidden">
{useTable(
{
columns: [], // 실제 컬럼은 외부에서 전달받음 → 여기서는 재사용을 위해 별도 선언 안 함
data: [rowData],
getCoreRowModel,
getRowHeight: () => rowHeight,
},
useRowVirtualization()
).rows.map((row) =>
// 실제 셀 렌더링은 외부 Table 컴포넌트에서 담당하므로 여기서는 스켈레톤만
// (예시) <CellComponent row={row} column={col} />
// → 실제 사용 시 `useReactTable` 로 만든 `tableRows` 를 재사용
)}
</div>
</div>
);
};
/* ---------- 10️⃣ Actions 정의 (React 19) ---------- */
const useActions = () => {
return {
setPage: (page: number) => console.log('setPage:', page), // 필요 시 구현
setPageSize: (size: number) => console.log('setPageSize:', size),
fetchRows: (page: number, pageSize: number, sort: unknown, filters: unknown) => {
// 실제 API 호출은 props 로 전달된 fetchRows 를 사용
// 여기서는 예시용 stub
return Promise.resolve({
rows: [], // 실제 데이터가 들어오면 tableRows.setData 로 교체
pageCount: 1,
});
},
};
};
```
> **⚠️ 주의**
> 위 코드는 **핵심 아이디어**와 **구조**를 보여주기 위한 스켈레톤이며, 실제 프로젝트에서는 다음 부분을 다듬어야 합니다.
| 필요 구현 | 설명 |
|-----------|------|
| **셀 렌더링** | `RowComponent` 안에서 `row.rows.map` 로 셀을 실제로 그리세요. `column.getCellProps`, `column.renderCell` 등을 활용하면 됩니다. |
| **가상화된 높이** | `row._virtualSize` 를 자동 계산하거나 `rowHeight` 를 직접 반환하는 `getRowHeight` 콜백을 구현하세요. TanStack Table 은 `useRowVirtualization` 으로 height 를 자동 추정합니다. |
| **Shift‑range 선택** | 현재 구현은 `onKeyDown` 에서 `Shift` 키를 감지하지만, 실제 선택 로직은 `rowSelection.toggleRangeSelection(rowId, startId)` 로 구현해야 합니다. TanStack Table 은 `rowSelection` 에 `toggleRangeSelection` 을 제공하니 참고. |
| **ARIA grid 구현** | `grid` 객체에 `role="grid"` 와 `aria-rowcount`, `aria-colcount` 를 넘겨주긴 했지만 셀(cell) 수준 ARIA 속성(`aria-colindex`, `aria-selected` 등)도 필요합니다. |
| **스크롤 동기화** | `VariableSizeList` 와 TanStack Table 의 `rowVirtualizer` 가 서로 다른 스크롤 위치를 가질 수 있으니, `listProps.scrollToIndex` 혹은 `listProps.scrollToOffset` 을 조정해 양쪽을 동기화합니다. |
| **서버 페이징** | 현재는 전체 데이터를 메모리 상에 두고 가상 스크롤만 동작합니다. 실제 데이터가 10 000 + 행이라면 `fetchRows` 를 페이지 단위로 호출하고, `rowVirtualizer` 에 `estimatedItemSize` 로 페이지당 행 수를 전달해 **무한 스크롤** 형태로 구현하면 메모리 사용량을 크게 줄일 수 있습니다. |
---
## 5️⃣ 핵심 로직 요약
1. **가상 스크롤**
```ts
const rowVirtualizer = useMemo(() => {
return {
getItemSize: (i) => (rows[i]._virtualSize ?? 48) as number,
};
}, [rows]);
```
`VariableSizeList` 에 `itemCount={rows.length}` 와 `getItemSize` 를 넘겨 가변 높이를 지원합니다.
2. **Sticky Header & First Column**
- `headerStyle` 에 `position: sticky; top: 0;` (Tailwind `sticky top-0` 등)
- `firstColStyle` 에 `position: sticky; left: 0;` 로 첫 컬럼을 고정합니다.
3. **ARIA grid**
```ts
const grid = useMemo(() => ({
role: 'grid',
ariaRowCount: rows.length,
ariaColumnCount: columns.length,
}), [rows.length, columns.length]);
```
`<table>` 대신 `<div>` 를 감싸고 `role="grid"` 로 선언해 스크린리더가 그리드라 인식하게 합니다.
4. **정렬·필터·디바운스**
- `getSortByToggleFn` 과 `getFilterFn` 은 외부에서 구현된 함수를 전달받아 `column.toggleSort()` / `column.setFilter` 로 호출합니다.
- `@tanstack/table-core` 의 `useDebouncedCallback` (여기선 `use-debounce` 패키지) 로 300 ms 디바운스를 적용했습니다.
- `fetchRows` 가 서버에서 페이지/정렬/필터를 받아오고, `setData` 로 테이블을 갱신합니다.
5. **키보드 네비게이션**
- `VariableSizeList` 가 내부적으로 스크롤을 담당하므로, `keydown` 이벤트에서 `rowVirtualizerRef.current.scrollToIndex` 로 인덱스 이동을 구현했습니다.
- `ArrowUp/Down` → 한 행씩 이동, `PageUp/Down` → 페이지 단위 이동.
- `Shift` + `Enter` 로 범위 선택을 트리거하도록 `rowSelection.toggleRangeSelection` 을 호출하면 됩니다.
6. **다중 행 선택**
- `useRowSelection` 을 `mode: 'multiple'` 로 사용하고, 체크박스와 `toggleSelection` 으로 선택/해제합니다.
- `selectedRowIds` 로 현재 선택된 행들의 Set 을 관리할 수 있습니다.
---
## 6️⃣ 사용 예시
```tsx
/* src/App.tsx */
import { VirtualScrollTable } from './components/VirtualScrollTable';
import { useState, useEffect } from 'react';
import type { Row } from '@tanstack/table-core';
type MockRow = {
id: string;
name: string;
age: number;
country: string;
email: string;
phone: string;
address: string;
city: string;
zip: string;
salary: number;
joinDate: string;
isActive: boolean;
// … 12 columns total
};
const mockRows: MockRow[] = Array.from({ length: 10_000 }, (_, i) => ({
id: String(i),
name: `Name ${i}`,
age: 20 + i % 60,
country: `Country ${i % 20}`,
email: `user${i}@example.com`,
phone: `123-${i}`,
address: `Street ${i}`,
city: `City ${i % 10}`,
zip: `00${i}`,
salary: (100 + i) * 1000,
joinDate: new Date(Date.UTC(2020 + i % 5, i % 12, 1)).toISOString(),
isActive: i % 2 === 0,
}));
const columns = [
{
accessorKey: 'name',
header: 'Name',
size: 1,
},
{
accessorKey: 'age',
header: 'Age',
size: 1,
},
// … 나머지 10 컬럼 추가
];
const fetchRows = async (page: number, pageSize: number, sort: unknown, filters: unknown) => {
// 여기서는 가짜 데이터 반환 (실제 API 연동 시 fetchRows 로 교체)
// 페이지당 200 행씩 반환 → 가상 스크롤이 200 행씩 로드
const start = page * pageSize;
const end = Math.min(start + pageSize, mockRows.length);
const rows = mockRows.slice(start, end);
// 가상 스크롤 높이 계산을 위해 실제 전체 행 높이 배열을 넘겨줍니다.
return {
rows,
pageCount: Math.ceil(mockRows.length / pageSize),
};
};
export default function App() {
const [rows, setRows] = useState<MockRow[]>([]);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(200);
const [sorting, setSorting] = useState<unknown[]>([]);
const [filters, setFilters] = useState<unknown>({});
// 초기 로드
useEffect(() => {
fetchRows(0, pageSize, [], {}).then(({ rows }) => setRows(rows));
}, [pageSize]);
// 정렬·필터 시 페이지 초기화
useEffect(() => {
if (sorting.length === 0 && Object.keys(filters).length === 0) return;
setPage(0);
setRows(fetchRows(0, pageSize, sorting, filters).rows);
}, [sorting, filters]);
return (
<div className="p-4 max-w-7xl mx-auto">
<VirtualScrollTable
rows={rows}
columns={columns}
getSortByToggleFn={() => (desc?: boolean) => setSorting(prev => [desc ? { id: 'name', desc } : { id: 'name', desc: false }])}
getFilterFn={(colId) => (value: unknown) => (row: MockRow) => {
// 간단 문자열 필터
const colVal = String(row[colId]);
const filterVal = String(value);
return colVal.includes(filterVal);
}}
fetchRows={fetchRows}
emptyMessage="데이터가 없습니다."
/>
</div>
);
}
```
> **핵심 포인트**
> - `fetchRows` 가 페이지 단위로 데이터를 반환하고, `pageSize` 를 가상 스크롤 리스트 높이에 맞춰 동적으로 지정합니다.
> - `sorting` 과 `filters` 가 바뀔 때마다 페이지 인덱스를 0 으로 초기화하고 새로운 데이터를 다시 로드합니다. (실무에서는 페이지 유지 로직을 구현하면 됩니다.)
---
## 7️⃣ 마무리
* **가변 행 높이** → `react-window` 의 `VariableSizeList` + TanStack Table 의 `getRowHeight` 로 해결
* **Sticky Header / First Column** → Tailwind `sticky top-0` / `sticky left-0` 로 구현 (필요 시 `zIndex` 조절)
* **키보드 네비게이션** → `VariableSizeList` 의 `scrollToIndex` 로 가상 스크롤 이동, `Arrow`, `PageUp/Down`, `Shift` 로 셀 선택 구현
* **정렬·필터·디바운스** → TanStack Table 의 `column.toggleSort`, `column.setFilter` + `useDebounce` 로 300 ms 지연
* **다중 선택·Shift‑range** → `useRowSelection` 의 `toggleRangeSelection` 로 처리 (예시에서는 `Enter` 로 트리거)
* **ARIA grid** → `role="grid"` 와 `aria-rowcount/aria-colcount` 로 선언, 셀마다 `aria-selected` 등 추가 가능
* **React 19 활용** → `use` 훅, `Actions` 로 선언형 API 제공, `useTransition` 은 필요 시 사용 가능
위 코드를 기반으로 실제 프로젝트에서는
1. 셀 렌더링을 `useTable` 로 만든 `cells` 를 재활용하거나 직접 구현
2. `rowVirtualizer` 와 `VariableSizeList` 를 동기화하는 로직을 다듬고, 페이지네이션·무한 스크롤을 적용
3. `shift`‑range 선택 로직을 `rowSelection.toggleRangeSelection` 으로 완성
4. 모든 셀에 `aria-colindex`, `aria-selected`, `tabIndex` 등을 부여해 접근성 강화
를 진행하면 **10 000 행, 12 컬럼** 규모의 가상 스크롤 테이블이 React 19·Tailwind·TypeScript 환경에서 매끄럽게 동작합니다. 🚀
궁금한 점이나 특정 셀 UI 구현이 필요하면 언제든 알려 주세요! Happy coding!채점 근거react-window + @tanstack/table-core 선택 자체는 무난하나 'React 19 전용 useTable 훅'·'useRowSelection Actions'·'use 훅' 등 존재하지 않는 React 19 전용 API를 다수 날조. 장황하나 핵심 API 환각.
Qwen 3.5 9BAlibaba526860707866
React 19, Tailwind 4, TypeScript 환경에서 **10,000 행 x 12 컬럼**의 대규모 데이터를 효율적으로 렌더링하기 위해 다음과 같은 라이브러리와 전략을 선택했습니다.
### 1. 라이브러리 선정 및 근거
| 라이브러리 | 선정 근거 |
| :--- | :--- |
| **@tanstack/react-table** | 정렬 (Sorting), 필터링 (Filtering), 행 선택 (Row Selection) 로직을 추상화하여 비즈니스 로직과 UI 를 분리합니다. |
| **@tanstack/react-virtual** | `react-window` 와 유사한 성능을 제공하며, TanStack Table 과의 연동이 매우 직관적입니다. **가변 높이 (Variable Height)** 처리에 커스텀 `estimateSize` 와 `rowHeight` 계산 로직을 적용하여 최적화했습니다. |
| **Tailwind CSS v4** | CSS 자바스크립트 (CSS-in-JS) 기반의 스타일링. 자동 사용자 정의 (CSS Variables) 를 통해 Tailwind 플러그인 없이도 `h-ltr` (Left-to-Right), `sticky` 등 복잡한 레이아웃을 쉽게 구현할 수 있습니다. |
| **이클립스 (Eclipse) - 구현** | 외부 의존성 최소화. `useDebounce` 커스텀 훅으로 리액트 외부에서 300ms 디바운싱 구현. |
---
### 2. 전체 코드 (React 19 / TS Compatible)
```tsx
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { createColumnHelper } from '@tanstack/react-table';
import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, flexRender, SortingState, VisibilityState } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; // 아이콘 임의 사용
// 1. 데이터 타입 정의
interface DataRow {
id: string;
name: string;
status: 'Active' | 'Pending' | 'Inactive';
date: string;
value: number;
category: string;
email: string;
phone: string;
address: string;
role: string;
description: string;
score: number;
}
// 2. 가상의 …채점 근거React19 가상스크롤 테이블(10k×12): 라이브러리 선택은 정확·최신(@tanstack/react-table + react-virtual). 그러나 명시 요구 대부분 미해결/버그: (1) 가변높이를 rowHeight=50 고정으로 회피하고 '72px서 잘릴 수 있음' 스스로 인정(measureElement 미사용), (2) sticky 헤더를 columnDef엔 정의했으나 렌더 루프가 getHeaderGroups()를 안 그려 헤더가 화면에 아예 안 뜸, (3) 키보드 nav가 row.element(존재 안 하는 속성) 참조로 작동 불가, (4) Shift 범위선택은 '기능 사용' 언급뿐 실제 미구현, (5) 라이브러리 표에 '이클립스(Eclipse)' 환각 행. ARIA(grid/row/gridcell)·디바운스 훅은 양호. 스캐폴딩은 정돈됐으나 hard 제약들이 깨짐.
Gemma 4 31BGoogle808060608076
React 19, Tailwind 4, 그리고 TypeScript 환경에서 요구하신 고성능 가상 스크롤 테이블을 구현하겠습니다.
### 1. 라이브러리 선택 및 근거
* **`@tanstack/react-table` (v8)**: 테이블의 상태 관리(정렬, 필터링, 선택)를 위한 **Headless UI** 라이브러리입니다. 12개 컬럼의 복잡한 상태와 Shift-선택 로직을 직접 구현하는 것보다 검증된 상태 머신을 사용하는 것이 유지보수와 버그 방지에 유리합니다.
* **`@tanstack/react-virtual` (v3)**: 가변 높이(Variable Height)를 지원하는 가장 강력한 가상화 라이브러리입니다. `measureElement` API를 통해 렌더링 후 실제 DOM 높이를 측정하여 동적으로 스크롤 위치를 계산하므로, 컨텐츠에 따른 36~72px 가변 높이 요구사항을 완벽하게 충족합니다.
### 2. React 19 신기능 활용 전략
* **`useTransition`**: 서버 페칭(정렬/필터) 시 UI가 멈추지 않고 "Pending" 상태를 부드럽게 표시하기 위해 사용합니다.
* **`use` 훅**: 비동기 데이터 로딩 시 Suspense와 결합하여 데이터 fetching 로직을 단순화합니다.
* **Ref 최적화**: React 19에서는 `ref`가 prop으로 전달 가능하므로 `forwardRef` 없이 구현합니다.
---
### 3. 전체 구현 코드
```tsx
import React, {
useState,
useMemo,
useRef,
useEffect,
useTransition
} from 'react';
import {
useReactTable,
getCoreRowModel,
flexRender,
ColumnDef,
SortingState,
ColumnFiltersState,
RowSelectionState
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
// --- Types ---
interface RowData {
id: string;
name: string;
email: string;
status: string;
category: string;
value: number;
description: string; // 가변 높이를 유발하는 필드
[key: string]: any;
}
// --- Mock API ---
const fetchServerData = async (sort: SortingState, filters: ColumnFiltersState) => {
// 서버 통신 시뮬레이션 (300ms 지연)
await new Promise((resolve) => setTimeout(resolve, 300));
return Array.from({ le…채점 근거전 요건 충족하나 react-window 등 대안 비교 누락+React19 전략 다소 일반적(use훅 Suspense 결합 generic)
EXAONE 4.5 33BLG AI406040407053
### React 19 + Tailwind 4 + TypeScript 가상 스크롤 테이블 컴포넌트 구현
#### 📦 **선택 근거 (Why TanStack Table + react-window?)**
1. **TanStack Table (v8)**
- **서버 사이드 정렬/필터링 최적화**: `useFilters`, `useSortBy` 훅으로 API 요청 디바운스(300ms)를 내장 지원.
- **가상화 통합**: `virtualization` 옵션을 통해 `react-window`와 연동해 1만 행 데이터도 60~70ms 렌더링 가능.
- **접근성**: ARIA 역할(`role="grid"`) 및 정렬 상태 자동 관리.
- **React 19 호환**: React 18 기반이지만 React 19의 `useTransition`/`Suspense`와 충돌 없이 동작 (React 19는 하위 호환).
- **다중 선택**: `useRowSelection`으로 Shift 키 범위 선택 구현 용이.
2. **react-window (v4)**
- **가변 높이 지원**: `VariableSizeList`로 36~72px 동적 행 높이 처리 (서버에서 전달된 `rowHeight` 값 또는 콘텐츠 길이 기반 계산).
- **성능**: 화면 외부 행 렌더링 차단 → 메모리/렌더링 오버헤드 90% 감소.
- **React 19 호환**: `useMemo`/`useCallback` 조합으로 가상화 로직 최적화 가능.
> 💡 **React 19 신기능 활용 여부**
> - `useTransition`/`Suspense`는 **사용하지 않음** (가상 스크롤은 상태 관리보다 렌더링 최적화에 집중).
> - `useDebounce` 대신 **TanStack Table 내장 디바운스** 활용 (React 19의 `useActions` 미지원 환경에서 안정성 확보).
> - **React 19 호환성**: 모든 훅은 `useState`, `useEffect` 기반으로 작성 (React 19에서도 정상 동작).
---
### ✅ 최종 코드 (TypeScript + Tailwind 4 + React 19)
```tsx
import { useTable, useColumns, useSortBy, useFilters, useRowSelection, TableInstance } from 'react-table';
import { VariableSizeList as List } from 'react-window';
import { useDebounce } from 'use-debounce'; // 디바운스 훅 (TanStack Table과 병행 사용)
import { useRef, useEffect } from 'react';
// 💡 가상 높이 계산 (실제 프로젝트에선 서버에서 전달받은 rowHeight 사용 권장)
const getRowHeight = (index: number, row: any) => {
// 예시: 콘텐츠 길이 기반 높이 추정 (36~72px)
const contentLength = row.someCell?.length || 0;
return 36 + Math.min(contentLength / 5, 36);
};
// 1. 컬럼 정의 (12개)
const columns = [
{ Header: 'ID', accessor: 'id' },
{ Header: 'Name', accessor: 'name', filterable: true },
{ Header: 'Email', accessor: 'email', filterable: true },
// ... (나머지 9개 컬럼)
{ Header: 'Status', accessor: 'status', filterable: true },
// ... (접근성을 위해 모든 컬럼에 role="columnheader" 추가)
];
// 2. 가상 스크롤 테이블 컴포넌트
const VirtualizedTable: React.FC<{ data: any[] }> = ({ data }) => {
const [selectedRowIds, setSelectedRowIds] = useState<number[]>([]);
const [lastSelectedRowId, setLastSelectedRowId] = useState<number | null>(null);
const [keyDownTimeout, setKeyDownTimeout] = useDebounce(0, 300); // 키보드 디바운스 (필요 시)
// TanStack Table 인스턴스 생성
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { sortBy, filter, ... },
setSortBy,
setFilter,
getRowId,
} = useTable(
{
columns,
data,
getRowId: (row) => row.id,
enableRowSelection: true,
initialState: { sortBy: [], filter: '' },
virtualization: {
height: 600, // 테이블 고정 높이
itemCount: data.length,
itemSize: (index) => getRowHeight(index, data[index]), // 가변 높이 계산
},
},
useColumns,
useSortBy,
useFilters,
useRowSelection,
);
// 3. 키보드 네비게이션 (방향키 + PgUp/PgDn)
const handleKeyDown = (e: KeyboardEvent, rowIndex: number) => {
const [currentRow, ...rest] = rows;
if (!currentRow) return;
let newIndex = rowIndex;
switch (e.key) {
case 'ArrowDown':
newIndex = Math.min(rowIndex + 1, rows.length - 1);
break;
case 'ArrowUp':
newIndex = Math.max(rowIndex - 1, 0);
break;
case 'PageDown':
newIndex = Math.min(rowIndex + 10, rows.length - 1);
break;
case 'PageUp':
newIndex = Math.max(rowIndex - 10, 0);
break;
default:
return;
}
// ARIA 포커스 이동 (실제 구현에선 ref로 관리 권장)
console.log(`Navigate to row index: ${newIndex}`);
};
// 4. Shift 키 범위 선택
const handleRowClick = (row: any, event: React.MouseEvent) => {
if (event.shiftKey && lastSelectedRowId !== null) {
const start = Math.min(lastSelectedRowId, row.id);
const end = Math.max(lastSelectedRowId, row.id);
// 서버에서 전체 ID 목록 요청 후 선택 (실제로는 index 기반 계산 권장)
const newSelection = data
.filter((_, i) => i >= start && i <= end)
.map((r) => r.id);
setSelectedRowIds(newSelection);
}
setLastSelectedRowId(row.id);
};
// 5. ARIA 역할 설정
const tableProps = getTableProps({ role: 'grid' });
return (
<div role="region" aria-label="가상 스크롤 테이블">
{/* 6. Sticky Header + First Column */}
<table
{...tableProps}
className="w-full border border-gray-200"
role="grid"
onKeyDown={(e) => {
// 키보드 이벤트 핸들링 (실제 구현에선 ref로 현재 행 인덱스 추적)
handleKeyDown(e, 0);
}}
>
{/* Header */}
<thead className="sticky top-0 bg-white shadow-md border-b border-gray-200">
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()} role="row" aria-roledescription="rowheader">
{headerGroup.headers.map((column) => (
<th
{...column.getHeaderProps()}
role="columnheader"
aria-sort={column.isSorted ? (column.isSortedDesc ? 'descending' : 'ascending') : 'none'}
className={column.column.CanFilter ? 'cursor-pointer' : ''}
>
{column.render('Header')}
{column.column.CanFilter && <span className="text-xs opacity-70">(필터)</span>}
</th>
))}
</tr>
))}
</thead>
{/* Body (가상 스크롤) */}
<tbody {...getTableBodyProps()} role="rowgroup">
<List
itemCount={rows.length}
itemSize={() => 0} // 실제 높이는 TanStack Table이 관리 (가상 스크롤 래퍼)
height={600}
width="100%"
onScroll={({ scrollTop }) => {
// 스크롤 이벤트 핸들링 (필요 시)
}}
>
{({ index, style }) => {
const row = rows[index];
if (!row) return null;
return (
<div
{...row.getRowProps()}
role="row"
style={style}
className={`border-b border-gray-100 ${
selectedRowIds.includes(row.id) ? 'bg-blue-50' : ''
}`}
onClick={(e) => handleRowClick(row, e)}
tabIndex={0}
>
{row.cells.map((cell, i) => (
<div
{...cell.getCellProps()}
role={i === 0 ? 'rowheader' : 'gridcell'}
className={i === 0 ? 'sticky left-0 bg-white border-r border-gray-200' : ''}
>
{cell.render('Cell')}
</div>
))}
</div>
);
}}
</List>
</tbody>
</table>
</div>
);
};
export default VirtualizedTable;
```
---
### 🔍 **핵심 기능 구현 설명**
#### 1. **가변 행 높이 처리**
- `virtualization.itemSize`에서 `getRowHeight(index, row)`로 동적 높이 계산 (예: 콘텐츠 길이 기반).
- **실제 프로젝트 권장**: 서버에서 `rowHeight` 필드를 전달받아 정확한 높이 사용.
#### 2. **Sticky Header + Sticky First Column**
- **Header**: `sticky top-0 bg-white shadow-md` (Tailwind 클래스).
- **First Column**: `sticky left-0 bg-white border-r` (첫 번째 셀에 별도 클래스 적용).
#### 3. **키보드 네비게이션 + ARIA**
- **키 이벤트**: `onKeyDown`으로 방향키/페이지 이동 처리 (실제 구현에선 `ref`로 현재 행 추적).
- **ARIA**:
- `role="grid"` (테이블 전체)
- `role="columnheader"` (헤더)
- `role="row"` (행), `role="gridcell"` (셀)
#### 4. **서버 사이드 정렬/필터링 (디바운스 300ms)**
- TanStack Table의 `useFilters`/`useSortBy`로 API 요청 디바운스 처리.
- **예시** (부모 컴포넌트에서):
```ts
const [filter, setFilter] = useDebounce('');
useEffect(() => {
fetchData({ filter: setFilter.value, sortBy: sortBy });
}, [setFilter.value, sortBy]);
```
#### 5. **다중 선택 + Shift 범위 선택**
- `useRowSelection`으로 기본 선택 관리.
- **Shift 키 처리**:
- `lastSelectedRowId`로 이전 선택 행 추적.
- `start/end` 계산 후 서버에서 전체 ID 목록 요청 (또는 인덱스 기반 계산).
---
### ⚠️ **주의사항 & 최적화 팁**
1. **가변 높이 정확도**
- `react-window`의 `itemSize`는 **사전에 계산되어야 함**.
- 실제 렌더링 전 높이 측정이 어려울 경우, **서버에서 `rowHeight` 필드 전달**이 가장 정확함.
2. **키보드 네비게이션 개선**
- `ref`로 현재 행 인덱스 추적 → `handleKeyDown`에서 정확한 이동 로직 구현.
- `tabIndex` 관리로 포커스 순서 제어.
3. **React 19 호환성**
- `useEffect` 정리 (cleanup)는 키보드 이벤트 핸들러에서 불가피하나, **React 19의 `useActions`로 상태 업데이트 최적화** 가능 (이 예제에선 단순화).
4. **성능 모니터링**
- `react-window`의 `onScroll`으로 스크롤 위치 추적 → 가상화 영역 외부 렌더링 방지.
---
### 📌 **결론: 왜 이 라이브러리 조합인가?**
- **TanStack Table**은 **서버 사이드 데이터 관리**에 최적화되어 정렬/필터링 로직을 간결하게 처리.
- **react-window**는 **가변 높이 가상 스크롤**을 구현하는 데 필수적이며, 1만 행 데이터도 부드럽게 렌더링.
- **React 19 호환**을 위해 `useDebounce` 대신 TanStack Table 내장 디바운스 활용 → **의존성 최소화 + 안정성 확보**.
- **접근성**을 위해 ARIA 역할 완전 적용 → 스크린 리더 사용자 경험 보장.
> ✨ **추가 권장 사항**:
> - 실제 프로덕션에선 `rowHeight`를 서버에서 전달받아 `getRowHeight`에서 **정확한 측정** (예: `window.getComputedStyle` 대신 서버 측 렌더링).
> - 1만 행 이상 데이터 시 **무한 스크롤** 대신 **페이지네이션** 고려 (서버 부하 감소).채점 근거TanStack Table v8 + react-window를 쓴다고 선언해놓고 실제 import·API는 react-table v7(useTable/useSortBy/useRowSelect)을 사용 — v8과 v7 API 혼동. react-window 가상화 골격은 그럴듯하나 핵심 라이브러리 API 환각.
HyperCLOVAX SEED Think 32BNaver206040604040
다음은 TanStack Table 라이브러리를 사용한 가상 스크롤 테이블 구현입니다. 선택 이유와 구현 세부사항을 포함했습니다:
**1. 라이브러리 선택 근거 (TanStack Table)**
- ✅React 18+ 호환성 (React 19의 useId, useTranslate 등 활용 가능)
- ✅기본 제공 가상 스크롤 (windowing) 및 sticky header/columns
- ✅접근성 내장 (ARIA grid role 자동 적용)
- ✅확장 가능한 상태 관리 (정렬/필터/선택)
- ✅TypeScript 첫 클래스 라이브러리
**2. 주요 구현 코드 (TypeScript)**
```tsx
import { Table, useTable, useSortBy, useFilters, useSelect, Column, useDebounce }from 'tanstack-table'
import { useId } from 'react-id-generator'
// 가상 데이터 생성
const generateData = (count: number) =>
Array.from({length: count}, (_, i) => ({
id: useId(),
content: `Row ${i}` + (i % 3 === 0 ? 'Long content that might increase row height' : ''),
// ... 나머지 11개 컬럼
}))
const TableComponent = (props: { pageSize?: number }) => {
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state,
debug
} = useTable(
{
columns: 12,
data: generateData(10000),
initialSort: { id: 'content', direction: 'asc' },
initialState: { pageIndex: 0 },
pageSize: props.pageSize || 50
},
useSortBy,
useFilters,
useSelect
)
// 디바운스 적용 필터 핸들러
const [filter, setFilter] = useState('')
const debouncedFilter = useDebounce((value: string) => {
setFilter(value)
}, 300)
return (
<div>
{headerGroups.map((headerGroup) => (
<div {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => (
<d…채점 근거TanStack Table v7 구 API(useTable/useSortBy)·존재안하는 훅(useSelect/useNavigation/useDebounce/useSelectAll)·잘못된 패키지명·useId를 react-id-generator서 import·map내 훅 호출. TanStack Virtual 미사용(Table엔 가상화 없음, 오답). ARIA 자동적용 overclaim. 5요건 nominal 언급.
Gemma 4 E2BGoogle425040424444
요청하신 요구사항은 고성능, 복잡한 상호작용(가변 높이, 키보드 네비게이션, 다중 선택, 디바운스 필터링)을 모두 요구하는 고급 데이터 그리드 컴포넌트입니다.
이러한 요구사항을 충족시키기 위해, **TanStack Table**과 **react-window** 라이브러리를 결합하는 것이 가장 효율적입니다.
### 💡 라이브러리 선택 근거
1. **TanStack Table (v8+):**
* **선택 이유:** 복잡한 데이터 그리드의 상태 관리(정렬, 필터링, 그룹화, 행 선택) 로직을 완벽하게 처리합니다. 서버 데이터 페칭, 필터링 상태 관리, 그리고 행 선택(Selection) 로직을 통합적으로 관리할 수 있어, 요구사항 (4)와 (5)를 효율적으로 처리할 수 있습니다.
* **장점:** 유연성, 강력한 API, TypeScript 지원 우수.
2. **react-window:**
* **선택 이유:** 1만 행과 같은 대규모 리스트에서 렌더링 성능을 극대화하기 위한 **가상화(Virtualization)**를 담당합니다. 화면에 보이는 행만 렌더링하므로, 렌더링 부하를 최소화하여 고성능을 보장합니다.
* **장점:** 가상 스크롤 구현에 특화되어 있으며, DOM 노드 수를 획기적으로 줄여줍니다.
---
### 🛠️ 컴포넌트 구현 (React 19 + TypeScript + Tailwind CSS)
#### 1. 데이터 구조 및 타입 정의 (TypeScript)
```tsx
// src/types/index.ts
export interface DataRow {
id: number;
col1: string;
col2: number;
col3: string;
// ... 12개 컬럼 정의
col12: string;
content: string; // 가변 높이 결정에 사용될 핵심 콘텐츠
}
export interface ColumnDefinition {
accessorKey: keyof DataRow;
header: string;
sortable: boolean;
filterable: boolean;
minHeight: number; // 가변 높이의 최소값
maxHeight: number; // 가변 높이의 최대값
}
export interface TableState {
sortBy: (key: keyof DataRow) | null;
filterText: string;
selectedRows: number[];
}
```
#### 2. 가상 높이 계산 및 Row Component
가변 높이 요구사항 (1)을 충족하기 위해, 실제 콘텐츠 길이를 측정하여 높이를 동적으로 결정합니다.
```tsx
// src/components/VirtualRow.tsx
import React, { useRef, useState, useMemo } from 'react';
import { DataRow } from '../ty…채점 근거N+1 식별 일부, react-window 가변높이 부적합
Kanana 2 30B-A3B ThinkingKakao306040406047
React 19 + Tailwind 4 + TypeScript 환경에서 1만 행의 가상 스크롤 테이블을 구현하기 위해, **TanStack Table**과 **react-window**를 결합하여 사용합니다. 다음은 구조화된 솔루션입니다:
### 선택 근거:
1. **TanStack Table**:
- 정렬, 필터링, 선택, 가상화 등 모든 테이블 요구 사항을 처리합니다.
- 서버 사이드 렌더링과 가상화를 지원하여 성능을 보장합니다.
- ARIA 그리드 역할과 키보드 네비게이션을 위한 내장 훅을 제공합니다.
2. **react-window**:
- 가변 행 높이(36~72px)에 최적화된 가상화 렌더링.
- 1만 행 이상의 데이터셋에서 매끄러운 성능을 보장합니다.
3. **React 19 호환성**:
- `use*` 훅(React 19의 새로운 기능)을 사용합니다.
- `useEffect`의 불필요한 정리를 피하여 React 19의 새로운 라이프사이클을 준수합니다.
---
### ✅ 구현 코드:
```tsx
import React, { useState, useMemo, useCallback } from 'react';
import { useReactTable } from '@tanstack/react-table';
import { VariableSizeList } from 'react-window';
import { debounce } from 'lodash';
interface TableRow {
id: number;
name: string;
age: number;
// ... other columns
}
interface TableColumn {
key: string;
header: string;
// ... other column properties
}
interface VirtualizedTableProps {
data: TableRow[];
columns: TableColumn[];
}
const VirtualizedTable: React.FC<VirtualizedTableProps> = ({ data, columns }) => {
// 1. 테이블 상태 관리 (TanStack Table)
const table = useReactTable({
data,
columns,
getCoreRowModel: true,
getRowId: (row) => row.id,
getRowModel: true,
getSelectedRowModel: true,
getSortedRowModel: true,
getFilteredRowModel: true,
initialState: {
columns: [],
rows: [],
sorting: [],
pagination: { page: 0, pageSize: 10 },
filtering: { column: '', value: '' },
groupBy: [],
expanded: {},
selected: {},
},
columns: (keys) => columns.filter(col => keys.includes(col.key)),
});
// 2. 디바운스된 필터링 (300ms)
const [filter, setFilter] = useState('');
const debouncedFilter = useMemo(() =>
debounce((text) => setFilter(text), 300),
[setFilter]
);
useEffect(() => {
debouncedFilter(filter);
}, [filter, debouncedFilter]);
// 3. 정렬 핸들러
const [sortColumn, setSortColumn] = useState({ id: '', ascending: true });
const handleSort = useCallback((columnId) => {
setSortColumn({
id: columnId,
ascending: !sortColumn.id === columnId ? !sortColumn.ascending : true,
});
}, [sortColumn]);
// 4. 행 선택 핸들러
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const handleSelectRow = useCallback((rowId) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (newSet.has(rowId)) newSet.delete(rowId);
else newSet.add(rowId);
return newSet;
});
}, []);
// 5. 가상화 행 렌더러
const RowRenderer = ({ row, index }) => {
return (
<div role="row" key={row.id}>
{row.getAllCells().map(cell => (
<div role="gridcell" key={cell.column.id}>
{cell.getValue()}
</div>
))}
</div>
);
};
// 6. 가변 행 높이 (Tailwind 유틸리티 클래스 사용)
const RowHeights = useMemo(() =>
data.map(() => Math.floor(Math.random() * 37) + 36),
[data]
);
const RowCount = data.length;
const VirtualizedRows = () => {
return (
<VariableSizeList
height={500}
width={1000}
itemCount={RowCount}
itemSize={(index) => RowHeights[index]}
>
{RowRenderer}
</VariableSizeList>
);
};
return (
<div role="grid" className="min-h-screen">
{/* Sticky Header */}
<div
role="rowheader"
style={{ position: 'sticky', top: 0 }}
className="bg-gray-100"
>
{columns.map(col => (
<div
role="columnheader"
key={col.key}
className="px-4 py-2"
>
{col.header}
</div>
))}
</div>
{/* Sticky First Column */}
<div
role="rowheader"
style={{ position: 'sticky', left: 0, zIndex: 1 }}
className="bg-gray-200"
>
{columns.map((col, index) => (
<div
role="gridcell"
key={col.key}
className="px-4 py-2"
>
{index === 0 ? 'ID' : ''}
</div>
))}
</div>
{/* Virtualized Rows */}
<div className="h-[500px] w-full">
<VirtualizedRows />
</div>
</div>
);
};
export default VirtualizedTable;
```
---
### 🔑 주요 기능:
1. **가상화**:
- `react-window`의 `VariableSizeList`가 가변 행 높이(36~72px)를 효율적으로 렌더링합니다.
- 1만 행 이상의 데이터셋에서도 부드러운 성능을 보장합니다.
2. **고정 헤더/첫 번째 열**:
- CSS `position: sticky`를 사용하여 헤더와 첫 번째 열을 고정합니다.
3. **키보드 네비게이션**:
- ARIA 역할(`role="grid"`, `role="row"`, `role="gridcell"`)이 화면 리더 지원을 제공합니다.
- `useReactTable`이 기본 포커스 관리를 처리합니다.
4. **정렬 & 필터링**:
- 열 헤더 클릭 시 정렬이 활성화됩니다.
- 디바운스된(300ms) 필터링이 서버 측에서 동작합니다.
5. **행 선택**:
- 단일/다중 행 선택이 가능합니다.
- Shift-클릭 시 범위 선택이 지원됩니다.
---
### 📦 의존성:
```bash
npm install @tanstack/react-table react-window lodash
```
### 🧠 React 19 호환성:
- `useReactTable`, `useCallback`, `useMemo` 훅을 사용합니다.
- `useEffect`의 불필요한 정리를 피합니다(React 19의 새로운 라이프사이클을 활용).
이 솔루션은 성능을 유지하면서 모든 요구 사항을 효율적으로 충족합니다.채점 근거useReactTable에 getCoreRowModel:true·getSortedRowModel:true 등 모든 rowModel을 함수 호출이 아닌 boolean으로 전달(컴파일 불가), initialState 구조(columns:[]/rows:[]/pagination)도 날조. API 사용 심각 오류.
LFM2.5 8B-A1BLiquid AI445340434646
Below is a **complete, ready‑to‑copy** React 19 component that satisfies every requirement you listed. It uses **TanStack Table** (the de‑facto library for server‑driven data‑tables) together with **react‑window’s VariableSizeList** for true variable‑row‑height support. --- ## 1️⃣ Why TanStack Table + react‑window? | Requirement | How the chosen stack satisfies it | |-------------|-----------------------------------| | **Server‑side fetch, debounced filtering/sorting** | TanStack Table’s `useServerSideQuery` supports pagination, server‑side sorting & filtering out‑of‑the‑box, and it integrates seamlessly with a `react-window` renderer. | | **Virtual scrolling for 10 k rows** | `react-window` provides a `VariableSizeList` that measures each row’s actual height and renders only the visible rows – essential for > 5 k rows. | | **Sticky header & sticky first column** | TanStack Table renders the header as a `<thead>` that we style with `position: sticky` and `left: 0`. The first data column receives an extra `role="gridcell"` and `aria-selected` handling for selection. | | **Keyboard navigation (arrow keys, PgUp/PgDn) + ARIA grid role** | We attach a global `keydown` listener to the table container, move focus to the first column on page change, and use ARIA roles (`role="grid"`, `role="gridhead"`, `role="gridcell"`) plus `aria-selected` for each selected row. | | **Multi‑select with Shift‑range** | The component tracks selected rows in a `Set<rowIndex>`; clicking a row…
채점 근거TanStack+react-window 적절하나 useEffect 자기모순+환각 API
HyperCLOVAX SEED 1.5BNaver294129313132
**1. React 19와 Tailwind CSS를 사용한 스크롤 테이블 컴포넌트**
아래는 요구사항을 충족하는 React 19 및 Tailwind CSS 기반의 스크롤 테이블 컴포넌트입니다. 이 컴포넌트는 TypeScript를 사용하여 작성되었으며, 외부 라이브러리인 `@tan/table`을 활용하여 테이블을 구현했습니다.
```typescript
import { React, ReactComponent } from 'react';
import { Table, Column, rows, useTable, useSortBy } from '@tan/table';
import { v4 } from 'uuid/v4'; // UUID 생성을 위한 Node.js 라이브러리
import { useState, useEffect } from 'react';
interface ICell<T> {
key: string;
label: string;
render: ((args: any) => React.PureComponentargs<T, T>) | React functions;
}
interface ITableProps<T> {
// 기본 테이블 속성
// e.g. columns: [Column, Column, ..., Column],
// rows: [Row, Row, ..., Row],
// // e.g. width: '100%',
// // e.g. style: { ... },
}
interface ITable<T> {
// 테이블의 기본 속성
columns: ColumnDefinition<T>;
rows: RowDefinition<T>;
// // e.g. width: '100%;
// // e.g. style: { ... },
}
interface ColumnDefinition<T> {
key: string;
label: string;
render: ((args: { row: Row, column: Column, sortState: SortState }) => React.PureComponentArgs<T, React.SoryElement & React.PureComponentArgs<T, React.SoryElement>>);
}
interface RowDefinition<T> {
key: string;
render: ((args: { row: Row, sortState: SortState }) => React.PureComponentArgs<T, React.SoryElement & React.PureComponentArgs<T, React.SoryElement>>);
}
const TableComponent: ReactComponent<ITable<T>> = ({ columns, rows, ...props }) => {
const columns: ColumnDefinition<T> = columns.map((col) => ({
key: col.key,
…채점 근거가짜 @tan/table API