React 가상 스크롤 테이블
비공개 문항입니다. 학습 오염을 막기 위해, 측정 영상에서 응답 원문과 함께 본문을 공개합니다.
기대 포맷컴포넌트 + 성능 최적화 근거 + a11y
사전 정의 함정2건, 발동 모델에 ⚠ 표시
모델별 응답 · 채점
36개 모델. 점수만 보임 — 모델명 클릭 시 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 재현(설득력 高·미실행).
CMD 1.0 LiteCommander808080808080
좋은 질문입니다. 1만 행 × 12컬럼 데이터를 다루려면 **가상화(virtualization)가 필수**입니다. TanStack Table + @tanstack/react-virtual 조합이 가장 적합하며, 선택 근거와 함께 전체 구현체를 드리겠습니다.
---
## 라이브러리 선택 근거
| 라이브러리 | 선택 여부 | 이유 |
|---|---|---|
| **TanStack Table v8** | ✅ 채택 | headless + 필터/정렬/선택/키보드 내장, 타입 안전, JSX 오염 없음 |
| **@tanstack/react-virtual** | ✅ 채택 | TanStack Table과 동일 생태계, react-window 대비 row/col 동시 가상화 가능, measureRef로 가변 높이 네이티브 지원 |
| react-window | ❌ | 가변 높이 `VariableSizeList`는 별도 계산 필요, 컬럼 가상화 별도 구현 |
| react-virtuoso | ❌ | 가변 높이에 강하나 TanStack Table과 통합 시 커스텀 접착제 과다 |
> TanStack Table v8은 정렬·필터·행 선택·키보드 네비게이션·ARIA를 기본 제공 + `@tanstack/react-virtual`로 가상 스크롤을 플러그인 수준에서 연결할 수 있습니다. 동일 코어 팀이 관리하므로 API 설계가 일관됩니다.
## React 19 신기능 판단
- **`use()` 훅**: Suspense 경계 내에서만 동작 → 서버 fetch를 debounce 300ms로 제어하는 이 구조에서는 오히려 유연성이 떨어짐 → **미채택**
- **`useActionState`**: 폼 액션 전용 → 테이블에 부적합 → **미채택**
- **`useTransition`**: React 18에도 존재, 필터 입력 UX에 유효 → React 19에서도 동일하게 사용
- **`useOptimistic`**: 여기서는 불필요
→ **React 18 호환 패턴으로 작성** (React 19에서도 문제없이 동작)
---
## 구현체 (4개 파일)
### 1. `types.ts` — 도메인 타입
```typescript
export interface RowData {
id: string;
name: string;
email: string;
status: 'active' | 'inactive' | 'pending';
role: string;
department: string;
joinedAt: string;
salary: number;
phone: string;
location: string;
lastLogin: string;
projects: number;
}
export type ColumnKey = keyof RowData;
export interface ColumnDef {
key: ColumnKey;
header: string;
width: number;
minWidth?: num…채점 근거TanStack Table+react-virtual 선택 근거 명확, 5개 요건 모두 다룸, 가변 높이·sticky·ARIA grid·디바운스·Shift 범위 선택 구현. React 19 신기능은 의도적으로 미채택 판단을 적절히 설명. 다만 table.getHeader(col.key) 같은 일부 API 사용이 부정확하고 sticky 컬럼 헤더 분기 로직이 다소 fragile. 치명 오류는 없음.
시그니처“TanStack Table v8 + react-virtual — 가변 높이는 measureElement로 36~72px 클램프.”
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 훅. 완전
CMD 1.0 ProCommander80100806010088
# 가상 스크롤 테이블 컴포넌트 구현
## 라이브러리 선택 근거
| 라이브러리 | 선택 이유 |
|-----------|----------|
| **@tanstack/react-virtual** | 가변 행 높이를 네이티브 지원. `estimateSize`로 사전 추정 후 실제 측정값으로 동적 조정. react-window는 고정 높이 중심으로 가변 높이 시 별도 래퍼 필요 |
| **@tanstack/react-table** | Headless 테이블 로직(컬럼 정의, 정렬 상태, 필터 상태, 행 선택)을 제공. UI 분리로 Tailwind 커스텀 자유도 확보 |
---
## React 19 신기능 활용 판단
| 기능 | 활용 여부 | 이유 |
|------|----------|------|
| **`use()` 훅** | ✅ 활용 | 서버 fetch Promise를 Suspense 경계에서 직접 렌더링. `useEffect` + `useState` 조합 대체 |
| **`useTransition`** | ✅ 활용 | 정렬/필터 변경 시 pending 상태로 로딩 인디케이터 표시 |
| **`useOptimistic`** | ✅ 활용 | 행 선택/해제 시 서버 응답 전 UI 즉시 반영 |
| **Actions (form action)** | ❌ 불필요 | 폼 제출이 아닌 스크롤/클릭 기반 인터랙션 |
| **`ref` cleanup** | ✅ 적용 | React 19에서 `ref` 콜백 cleanup 자동 지원으로 ResizeObserver 해제 단순화 |
---
## 전체 구현 코드
```tsx
// types.ts
export interface DataRow {
id: string;
[key: string]: unknown;
}
export interface ColumnDef<T> {
key: string;
header: string;
width: number;
sortable?: boolean;
filterable?: boolean;
render?: (value: unknown, row: T) => React.ReactNode;
}
export interface SortState {
column: string;
direction: 'asc' | 'desc';
}
export interface FilterState {
[column: string]: string;
}
export interface TableState {
sort: SortState | null;
filters: FilterState;
selectedIds: Set<string>;
activeCell: { row: number; col: number } | null;
}
```
```tsx
// useVirtualTable.ts — React 19 신기능 활용
import { use, useTransition, useOptimistic, useCallback, useRef, useState …채점 근거TanStack Virtual + Table 선택근거 명시, 가변 높이/sticky/ARIA grid/키보드/Shift 범위/디바운스 모두 구현. React 19 use·useTransition·useOptimistic·ref cleanup 활용도 적절. 다만 실제 데이터 바인딩 부분이 비어 있고 Shift 범위 선택 로직이 가상 아이템 기준이라 화면 밖 행 범위 처리에 한계 — 사소한 누락.
시그니처“ResizeObserver는 보이는 가상 행에만 — 1만 행에 일괄 부착은 금물.”
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 과도
Tencent Hy3 PreviewTencent768278628578.7
React 19 + Tailwind 4(Alpha) + TypeScript 환경에서 요구사항을 충족하는 가상 스크롤 테이블 구현方案을 제시합니다.
### 1. 라이브러리 선택 근거
* **@tanstack/react-table (v8)**: 헤드리스 UI 라이브러리로, 정렬, 필터링, 행 선택(Selection), 다중 선택 등 복잡한 테이블 로직을 완벽하게 지원합니다. UI를 직접 제어할 수 있어 Tailwind와 결합하기 좋습니다.
* **@tanstack/react-virtual**: 1만 행 이상의 대용량 데이터 렌더링에 최적화되어 있습니다. React 19의 Concurrent Features와 잘 호환되며, 가변 높이(Variable Size) 측정 기능을 제공합니다.
* **Why not react-window?**: `react-window`는 고정 높이에 특화되어 있어 가변 높이 처리가 까다롭고, TanStack 생태계와 통합 시 추가 래핑이 필요합니다.
### 2. React 19 신기능 활용 여부
* **`use()` Hook**: 서버 상태(Suspense)와 연동하여 데이터를 가져오는 패턴을 시연합니다.
* **`useActionState` / Server Actions**: 정렬/필터 파라미터 변경 시 서버로 요청을 보내는 로직에 활용합니다.
* **Ref as a Prop**: `forwardRef` 없이 가상 스크롤 컨테이너에 Ref를 전달합니다.
* **`useEffect` cleanup 대신 `use()` 및 `Suspense`**: 데이터 fetching 로직을 React 19 스타일로 작성합니다.
### 3. 코드 구현
#### (1) 타입 정의 및 데이터 생성
```typescript
// types.ts
export interface DataRow {
id: string;
col1: string;
col2: number;
// ... col12까지 가정
[key: string]: any;
}
export type SortConfig = { id: string; desc: boolean }[];
export type FilterConfig = Record<string, string>;
```
#### (2) 가상 스크롤 테이블 컴포넌트
```tsx
// DataTable.tsx
import React, { useCallback, useMemo, useRef, useState, use } from 'react';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
createColumnHelper,
flexRender,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { DataRow, SortConfig, FilterConfig } from './types';
// React 19: Server Action 시뮬레이션 (디바운스 적용)
async function fetchData(
sort: SortConfig,
filters: FilterConfig
): Promise<DataRow[]> {
'use server'; // React 19 Server Action marker
// 실제로는 300ms 디바운스 후 fetch 로직
console.log('Fetching with:', sort, filters);
return new Array(10000).fill(null).map((_, i) => ({
id: `row-${i}`,
col1: `Item ${i}`,
col2: Math.random() * 100,
// ... 12 columns
}));
}
const columnHelper = createColumnHelper<DataRow>();
interface DataTableProps {
dataPromise: Promise<DataRow[]>;
}
export function DataTable({ dataPromise }: DataTableProps) {
// React 19: use() 훅으로 Promise 처리
const initialData = use(dataPromise);
const [sorting, setSorting] = useState<SortConfig>([]);
const [columnFilters, setColumnFilters] = useState<FilterConfig>({});
const [rowSelection, setRowSelection] = useState({});
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
// 디바운스 로직 (서버 요청 가정)
const handleFilterChange = useCallback((columnId: string, value: string) => {
// 실제 구현 시 useTransition + useActionState 활용 가능
setColumnFilters(prev => ({ ...prev, [columnId]: value }));
}, []);
const columns = useMemo(
() => [
columnHelper.accessor('col1', {
header: 'Name',
cell: info => <div className="truncate">{info.getValue()}</div>,
meta: { sticky: true } // Sticky 첫 컬럼 마커
}),
columnHelper.accessor('col2', { header: 'Value' }),
// ... 나머지 10개 컬럼 정의
],
[]
);
const table = useReactTable({
data: initialData,
columns,
state: { sorting, columnFilters, rowSelection },
onSortingChange: setSorting,
onColumnFiltersChange: (updater) => {
// 디바운스 처리 로직 (간소화)
const newFilters = typeof updater === 'function' ? updater(columnFilters) : updater;
setColumnFilters(newFilters);
},
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
});
const { rows } = table.getRowModel();
const parentRef = useRef<HTMLDivElement>(null);
// 가변 높이 가상화 (36~72px)
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 54, // 평균 높이 추정
measureElement: (element) => element?.getBoundingClientRect().height, // 실제 높이 측정
overscan: 10,
});
// 키보드 네비게이션 (ARIA Grid)
const handleKeyDown = (e: React.KeyboardEvent) => {
const { key } = e;
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'].includes(key)) {
e.preventDefault();
// 스크롤 및 포커스 로직 구현
}
};
// Shift 범위 선택
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
if (e.shiftKey && lastSelectedIndex !== null) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
const newSelection = { ...rowSelection };
for (let i = start; i <= end; i++) {
newSelection[rows[i].id] = true;
}
setRowSelection(newSelection);
} else {
row.toggleSelected(!row.getIsSelected());
setLastSelectedIndex(index);
}
};
return (
<div
ref={parentRef}
className="h-[600px] w-full overflow-auto relative border border-gray-200 rounded-lg"
onKeyDown={handleKeyDown}
role="grid"
aria-label="Data table"
tabIndex={0}
>
{/* Sticky Header */}
<div className="sticky top-0 z-10 bg-white shadow-sm">
{table.getHeaderGroups().map(headerGroup => (
<div key={headerGroup.id} role="row" className="flex">
{headerGroup.headers.map(header => (
<div
key={header.id}
className={`flex-none p-2 border-b font-bold text-sm
${header.column.columnDef.meta?.sticky ? 'sticky left-0 z-20 bg-white' : ''}
`}
style={{ width: header.getSize() || 150 }}
role="columnheader"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{/* 정렬 표시 */}
{header.column.getIsSorted() ? (header.column.getIsSorted() === 'desc' ? ' 🔽' : ' 🔼') : null}
{/* 필터 */}
<input
type="text"
placeholder="Filter..."
className="mt-1 w-full text-xs p-1 border rounded"
value={(columnFilters[header.id] as string) ?? ''}
onChange={e => handleFilterChange(header.id, e.target.value)}
/>
</div>
))}
</div>
))}
</div>
{/* Virtualized Body */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => {
const row = rows[virtualRow.index];
const isSelected = row.getIsSelected();
return (
<div
key={row.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={`absolute top-0 left-0 w-full flex border-b transition-colors
${isSelected ? 'bg-blue-100' : 'hover:bg-gray-50'}
`}
style={{
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`, // 가변 높이 적용
}}
role="row"
aria-selected={isSelected}
onClick={(e) => handleRowClick(row, virtualRow.index, e)}
>
{row.getVisibleCells().map(cell => (
<div
key={cell.id}
className={`flex-none p-2 text-sm overflow-hidden
${cell.column.columnDef.meta?.sticky ? 'sticky left-0 z-10 bg-inherit' : ''}
`}
style={{ width: cell.column.getSize() || 150 }}
role="gridcell"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
```
#### (3) App.tsx (React 19 `use` 활용)
```tsx
// App.tsx
import { Suspense, use } from 'react';
import { DataTable } from './DataTable';
import { fetchData } from './actions'; // Server Action
export default function App() {
// React 19: Promise를 컴포넌트 상위에서 생성하여 전달
const dataPromise = fetchData([], {});
return (
<div className="p-8 bg-gray-50 min-h-screen">
<h1 className="text-2xl font-bold mb-4">React 19 Virtual Table</h1>
<Suspense fallback={<div className="p-4">Loading Table...</div>}>
<DataTable dataPromise={dataPromise} />
</Suspense>
</div>
);
}
```
### 4. 요구사항 충족 확인
1. **가변 높이**: `measureElement` 옵션을 통해 각 행의 실제 높이를 측정하여 36~72px 처리.
2. **Sticky**: Tailwind의 `sticky top-0`, `sticky left-0`과 `z-index` 활용.
3. **키보드/ARIA**: `role="grid"` 및 `onKeyDown` 이벤트 처리.
4. **정렬/필터**: TanStack Table의 내장 기능 + `fetchData` Action을 통한 서버 통신 가정.
5. **행 선택**: `enableRowSelection` 및 `Shift` 키 감지 로직으로 범위 선택 구현.
### 5. React 19 & Tailwind 4 참고
* **React 19**: `use()` 훅을 통해 데이터 스트리밍을 처리하고, `<div ref={...}>` 방식으로 Ref를 직접 전달했습니다.
* **Tailwind 4**: Tailwind 4에서는 `@theme` 설정이나 Native CSS 변수 지원이 강화되었으나, 여기서는 기존 유틸리티 클래스(`flex`, `sticky`, `z-10` 등)를 사용하여 즉시 호환되도록 작성했습니다.
* **디바운스**: 필터 입력 시 `setTimeout`을 래핑하거나, 서버 액션에 `useTransition`을 적용하여 300ms 디바운스 효과를 낼 수 있습니다. (위 코드에서는 가독성을 위해 생략 후 주석 처리)채점 근거TanStack Table+Virtual+비교·sticky·ARIA·Shift·React19 use/Actions/ref ✅, 키보드nav stub·aria-rowindex 누락.
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
Grok 4.5xAI100100906010095
# React 19 가상 스크롤 테이블
## 라이브러리 선택 근거
| 라이브러리 | 선택 이유 |
|---|---|
| **`@tanstack/react-virtual`** | 가변 row height에 `measureElement` + ResizeObserver 네이티브 지원. `react-window`는 고정 높이 중심이라 36–72px 범위 측정에 불리. |
| **`@tanstack/react-table`** | headless로 sort/filter/selection 상태를 분리. UI는 Tailwind로 완전 제어. 서버 사이드 정렬·필터 모드(`manualSorting`/`manualFiltering`)가 요구사항과 맞음. |
`react-virtuoso`도 가변 높이에 강하지만, sticky 첫 컬럼 + ARIA grid + 키보드 그리드 네비를 세밀히 제어하려면 TanStack 조합이 더 낫습니다.
---
## React 19 신기능 활용 판단
| 기능 | 사용 여부 | 이유 |
|---|---|---|
| `use` | ✅ | 서버 fetch Promise를 컴포넌트에서 읽기 |
| `useTransition` | ✅ | 정렬·필터 적용을 non-blocking으로 |
| `useDeferredValue` | ✅ | 필터 입력 디바운스 보완(렌더 스톨 완화) |
| `useActionState` / Actions | ❌ | 테이블 인터랙션은 form POST 패턴이 아님 |
| `useOptimistic` | ❌ | 서버 확정 전 UI 낙관 업데이트가 필수는 아님 |
| ref as prop | ✅ | `forwardRef` 불필요 |
| React 18 cleanup 패턴 | 회피 | 측정/observer는 TanStack Virtual이 소유 |
---
## 의존성
```bash
npm i @tanstack/react-virtual @tanstack/react-table
```
---
## 타입 & 서버 API 가정
```ts
// types.ts
export type RowId = string;
export interface Person {
id: RowId;
name: string;
email: string;
department: string;
title: string;
status: "active" | "away" | "offline";
location: string;
phone: string;
joinedAt: string;
salary: number;
notes: string; // 가변 높이 유도용 긴 텍스트
tags: string;
}
export type SortDir = "asc" | "desc";
export interface TableQuery {
sortBy?: keyof Person;
sortDir?: SortDir;
filters: Partial<Record<keyof Person, string>>;
/** 데모: 서버가 필터·정렬 후 전체 배열 반환 (페이지네이션은 가상 스크롤이 대체) */
}
export interface FetchResult {
rows: Person[];
total: number;
}
```
```ts
// api/fetchPeople.ts — 서버 fetch 가정 (데모용 mock)
import type { FetchResult, Person, TableQuery } from "../types";
function compare(a: Person, b: Person, key: keyof Person, dir: "asc" | "desc") {
const av = a[key];
const bv = b[key];
const mul = dir === "asc" ? 1 : -1;
if (typeof av === "number" && typeof bv === "number") return (av - bv) * mul;
return String(av).localeCompare(String(bv), "ko") * mul;
}
/** 캐시 키 → Promise. React `use`와 함께 쓰기 위함 */
const cache = new Map<string, Promise<FetchResult>>();
export function fetchPeople(query: TableQuery, seed: Person[]): Promise<FetchResult> {
const key = JSON.stringify(query);
let p = cache.get(key);
if (!p) {
p = new Promise((resolve) => {
// 네트워크 지연 시뮬
setTimeout(() => {
let rows = [...seed];
for (const [k, v] of Object.entries(query.filters)) {
if (!v) continue;
const q = v.toLowerCase();
rows = rows.filter((r) =>
String(r[k as keyof Person]).toLowerCase().includes(q),
);
}
if (query.sortBy) {
const dir = query.sortDir ?? "asc";
rows.sort((a, b) => compare(a, b, query.sortBy!, dir));
}
resolve({ rows, total: rows.length });
}, 120);
});
cache.set(key, p);
}
return p;
}
export function invalidatePeopleCache() {
cache.clear();
}
/** 1만 행 시드 생성 */
export function createSeed(n = 10_000): Person[] {
const depts = ["Engineering", "Design", "Sales", "HR", "Finance", "Support"];
const statuses = ["active", "away", "offline"] as const;
const notesPool = [
"단기 프로젝트 참여 중.",
"온보딩 진행 · 멘토 배정 완료. 추가 교육 일정 협의 필요.",
"분기 목표 초과 달성. 다음 스프린트에서 크로스팀 협업 예정. 상세 노트는 위키 참조.",
];
return Array.from({ length: n }, (_, i) => {
const id = String(i + 1);
return {
id,
name: `User ${id}`,
email: `user${id}@example.com`,
department: depts[i % depts.length]!,
title: i % 3 === 0 ? "Senior Engineer" : i % 3 === 1 ? "Designer" : "Manager",
status: statuses[i % 3]!,
location: i % 2 === 0 ? "Seoul" : "Busan",
phone: `010-${String(1000 + (i % 9000)).padStart(4, "0")}-${String(i % 10000).padStart(4, "0")}`,
joinedAt: `202${i % 5}-0${(i % 9) + 1}-15`,
salary: 40_000_000 + (i % 50) * 1_000_000,
notes: notesPool[i % notesPool.length]!,
tags: i % 4 === 0 ? "core, mentorship" : i % 4 === 1 ? "remote" : "on-site",
};
});
}
```
---
## 디바운스 훅 (300ms) + React 19 transition
```ts
// hooks/useDebouncedFilters.ts
import { useEffect, useState, useTransition } from "react";
import type { Person } from "../types";
type Filters = Partial<Record<keyof Person, string>>;
export function useDebouncedFilters(delay = 300) {
const [draft, setDraft] = useState<Filters>({});
const [committed, setCommitted] = useState<Filters>({});
const [isPending, startTransition] = useTransition();
useEffect(() => {
const t = window.setTimeout(() => {
startTransition(() => setCommitted(draft));
}, delay);
return () => window.clearTimeout(t);
}, [draft, delay]);
return {
draftFilters: draft,
filters: committed,
setDraftFilter: (key: keyof Person, value: string) =>
setDraft((prev) => ({ ...prev, [key]: value })),
isFilterPending: isPending,
};
}
```
---
## 메인 컴포넌트
```tsx
// components/VirtualDataTable.tsx
"use client";
import {
use,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useTransition,
type CSSProperties,
type KeyboardEvent,
} from "react";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
createSeed,
fetchPeople,
invalidatePeopleCache,
} from "../api/fetchPeople";
import { useDebouncedFilters } from "../hooks/useDebouncedFilters";
import type { Person, TableQuery } from "../types";
const SEED = createSeed(10_000);
const COL_WIDTHS: Record<string, number> = {
select: 48,
name: 160,
email: 220,
department: 130,
title: 150,
status: 100,
location: 100,
phone: 140,
joinedAt: 110,
salary: 120,
notes: 280,
tags: 140,
};
const MIN_ROW = 36;
const MAX_ROW = 72;
function StatusBadge({ status }: { status: Person["status"] }) {
const map = {
active: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
away: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
offline: "bg-zinc-500/15 text-zinc-600 dark:text-zinc-300",
} as const;
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${map[status]}`}
>
{status}
</span>
);
}
export function VirtualDataTable() {
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [isSortPending, startSortTransition] = useTransition();
const { draftFilters, filters, setDraftFilter, isFilterPending } =
useDebouncedFilters(300);
// 키보드·Shift 범위 선택용
const [focus, setFocus] = useState<{ row: number; col: number }>({
row: 0,
col: 1, // 0 = select 컬럼
});
const lastAnchorRef = useRef<number | null>(null);
const parentRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const query: TableQuery = useMemo(
() => ({
sortBy: sorting[0]?.id as keyof Person | undefined,
sortDir: sorting[0] ? (sorting[0].desc ? "desc" : "asc") : undefined,
filters,
}),
[sorting, filters],
);
// 쿼리 변경 시 mock 캐시 무효화 후 새 Promise
const dataPromise = useMemo(() => {
invalidatePeopleCache();
return fetchPeople(query, SEED);
}, [query]);
const { rows: data } = use(dataPromise);
const columns = useMemo<ColumnDef<Person>[]>(
() => [
{
id: "select",
header: ({ table }) => (
<input
type="checkbox"
className="size-4 accent-indigo-600"
aria-label="전체 선택"
checked={table.getIsAllRowsSelected()}
ref={(el) => {
if (el) el.indeterminate = table.getIsSomeRowsSelected();
}}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="size-4 accent-indigo-600"
aria-label={`${row.original.name} 선택`}
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => e.stopPropagation()}
/>
),
size: COL_WIDTHS.select,
enableSorting: false,
},
{
accessorKey: "name",
header: "Name",
size: COL_WIDTHS.name,
},
{
accessorKey: "email",
header: "Email",
size: COL_WIDTHS.email,
},
{
accessorKey: "department",
header: "Department",
size: COL_WIDTHS.department,
},
{
accessorKey: "title",
header: "Title",
size: COL_WIDTHS.title,
},
{
accessorKey: "status",
header: "Status",
size: COL_WIDTHS.status,
cell: ({ getValue }) => (
<StatusBadge status={getValue() as Person["status"]} />
),
},
{
accessorKey: "location",
header: "Location",
size: COL_WIDTHS.location,
},
{
accessorKey: "phone",
header: "Phone",
size: COL_WIDTHS.phone,
},
{
accessorKey: "joinedAt",
header: "Joined",
size: COL_WIDTHS.joinedAt,
},
{
accessorKey: "salary",
header: "Salary",
size: COL_WIDTHS.salary,
cell: ({ getValue }) =>
new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
maximumFractionDigits: 0,
}).format(getValue() as number),
},
{
accessorKey: "notes",
header: "Notes",
size: COL_WIDTHS.notes,
cell: ({ getValue }) => (
<span className="line-clamp-3 whitespace-normal break-words text-left">
{getValue() as string}
</span>
),
},
{
accessorKey: "tags",
header: "Tags",
size: COL_WIDTHS.tags,
},
],
[],
);
const table = useReactTable({
data,
columns,
state: { sorting, rowSelection },
onSortingChange: (updater) => {
startSortTransition(() => setSorting(updater));
},
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
enableMultiRowSelection: true,
});
const tableRows = table.getRowModel().rows;
const leafColumns = table.getVisibleLeafColumns();
const colCount = leafColumns.length;
const totalWidth = leafColumns.reduce((s, c) => s + c.getSize(), 0);
const virtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 8,
// 가변 높이: 실제 DOM 측정 (36–72 클램프는 셀 CSS로 보조)
measureElement:
typeof window !== "undefined" &&
navigator.userAgent.indexOf("Firefox") === -1
? (el) => el.getBoundingClientRect().height
: undefined,
});
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
// focus row가 보이도록 스크롤
useEffect(() => {
if (tableRows.length === 0) return;
const clamped = Math.min(focus.row, tableRows.length - 1);
virtualizer.scrollToIndex(clamped, { align: "auto" });
}, [focus.row, tableRows.length, virtualizer]);
const selectRange = useCallback(
(from: number, to: number, additive: boolean) => {
const [start, end] = from < to ? [from, to] : [to, from];
setRowSelection((prev) => {
const next: RowSelectionState = additive ? { ...prev } : {};
for (let i = start; i <= end; i++) {
const id = tableRows[i]?.id;
if (id) next[id] = true;
}
return next;
});
},
[tableRows],
);
const toggleOne = useCallback(
(index: number, additive: boolean) => {
const id = tableRows[index]?.id;
if (!id) return;
setRowSelection((prev) => {
if (!additive) return { [id]: true };
const next = { ...prev };
if (next[id]) delete next[id];
else next[id] = true;
return next;
});
},
[tableRows],
);
const onRowClick = useCallback(
(index: number, e: React.MouseEvent) => {
if (e.shiftKey && lastAnchorRef.current != null) {
selectRange(lastAnchorRef.current, index, e.metaKey || e.ctrlKey);
} else {
toggleOne(index, e.metaKey || e.ctrlKey);
lastAnchorRef.current = index;
}
setFocus((f) => ({ ...f, row: index }));
},
[selectRange, toggleOne],
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (tableRows.length === 0) return;
const maxRow = tableRows.length - 1;
const maxCol = colCount - 1;
const page = Math.max(1, Math.floor((parentRef.current?.clientHeight ?? 400) / 48) - 1);
const move = (dr: number, dc: number) => {
setFocus((f) => ({
row: Math.min(maxRow, Math.max(0, f.row + dr)),
col: Math.min(maxCol, Math.max(0, f.col + dc)),
}));
};
switch (e.key) {
case "ArrowDown":
e.preventDefault();
move(1, 0);
break;
case "ArrowUp":
e.preventDefault();
move(-1, 0);
break;
case "ArrowRight":
e.preventDefault();
move(0, 1);
break;
case "ArrowLeft":
e.preventDefault();
move(0, -1);
break;
case "PageDown":
e.preventDefault();
move(page, 0);
break;
case "PageUp":
e.preventDefault();
move(-page, 0);
break;
case "Home":
e.preventDefault();
if (e.ctrlKey || e.metaKey) setFocus((f) => ({ ...f, row: 0 }));
else setFocus((f) => ({ ...f, col: 0 }));
break;
case "End":
e.preventDefault();
if (e.ctrlKey || e.metaKey) setFocus((f) => ({ ...f, row: maxRow }));
else setFocus((f) => ({ ...f, col: maxCol }));
break;
case " ":
case "Enter": {
e.preventDefault();
if (e.shiftKey && lastAnchorRef.current != null) {
selectRange(lastAnchorRef.current, focus.row, true);
} else {
toggleOne(focus.row, e.ctrlKey || e.metaKey || true);
lastAnchorRef.current = focus.row;
}
break;
}
case "a":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
table.toggleAllRowsSelected(true);
}
break;
default:
break;
}
},
[tableRows.length, colCount, focus.row, selectRange, toggleOne, table],
);
const selectedCount = Object.keys(rowSelection).filter((k) => rowSelection[k]).length;
const pending = isSortPending || isFilterPending;
const stickyFirst = (isHeader: boolean): CSSProperties => ({
position: "sticky",
left: 0,
zIndex: isHeader ? 30 : 10,
});
return (
<div className="flex h-[min(80vh,720px)] w-full flex-col gap-3 rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-950">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<div>
총 <strong className="text-zinc-900 dark:text-zinc-100">{data.length.toLocaleString()}</strong>행
{selectedCount > 0 && (
<span className="ml-2 rounded-md bg-indigo-500/10 px-2 py-0.5 text-indigo-700 dark:text-indigo-300">
{selectedCount.toLocaleString()} 선택
</span>
)}
</div>
{pending && (
<span className="animate-pulse text-xs text-indigo-600 dark:text-indigo-400">
서버 반영 중…
</span>
)}
</div>
<div
ref={parentRef}
className="relative min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 dark:border-zinc-800"
>
<div
ref={gridRef}
role="grid"
aria-rowcount={tableRows.length + 1}
aria-colcount={colCount}
aria-label="직원 데이터 테이블"
aria-busy={pending || undefined}
tabIndex={0}
onKeyDown={onKeyDown}
className="outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
style={{ width: totalWidth, height: totalSize + 88 }}
>
{/* Sticky header */}
<div
role="row"
aria-rowindex={1}
className="sticky top-0 z-20 flex border-b border-zinc-200 bg-zinc-50/95 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/95"
style={{ width: totalWidth, height: 88 }}
>
{table.getHeaderGroups().map((hg) =>
hg.headers.map((header, colIndex) => {
const isFirst = colIndex === 0;
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
const colKey = header.column.id as keyof Person;
const showFilter = header.column.id !== "select";
return (
<div
key={header.id}
role="columnheader"
aria-colindex={colIndex + 1}
aria-sort={
sorted === "asc"
? "ascending"
: sorted === "desc"
? "descending"
: canSort
? "none"
: undefined
}
className={`flex shrink-0 flex-col justify-center gap-1 border-r border-zinc-200 px-2 py-1.5 text-xs font-semibold text-zinc-700 dark:border-zinc-800 dark:text-zinc-200 ${
isFirst ? "bg-zinc-50/95 dark:bg-zinc-900/95" : ""
}`}
style={{
width: header.getSize(),
...(isFirst ? stickyFirst(true) : null),
}}
>
<button
type="button"
className={`flex items-center gap-1 text-left ${
canSort ? "cursor-pointer hover:text-indigo-600" : "cursor-default"
}`}
disabled={!canSort}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted === "asc" && <span aria-hidden>↑</span>}
{sorted === "desc" && <span aria-hidden>↓</span>}
</button>
{showFilter && (
<input
type="search"
className="w-full rounded border border-zinc-200 bg-white px-1.5 py-0.5 text-[11px] font-normal text-zinc-800 outline-none focus:border-indigo-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
placeholder="필터…"
value={draftFilters[colKey] ?? ""}
onChange={(e) => setDraftFilter(colKey, e.target.value)}
onClick={(e) => e.stopPropagation()}
aria-label={`${header.column.id} 필터`}
/>
)}
</div>
);
}),
)}
</div>
{/* Virtual body */}
<div
className="relative"
style={{ height: totalSize, width: totalWidth }}
>
{virtualRows.map((vRow) => {
const row = tableRows[vRow.index]!;
const isFocusedRow = focus.row === vRow.index;
const selected = row.getIsSelected();
return (
<div
key={row.id}
role="row"
aria-rowindex={vRow.index + 2}
aria-selected={selected || undefined}
data-index={vRow.index}
ref={virtualizer.measureElement}
onClick={(e) => onRowClick(vRow.index, e)}
className={`absolute left-0 flex cursor-pointer border-b border-zinc-100 dark:border-zinc-900 ${
selected
? "bg-indigo-50 dark:bg-indigo-950/40"
: isFocusedRow
? "bg-zinc-50 dark:bg-zinc-900/60"
: "bg-white hover:bg-zinc-50 dark:bg-zinc-950 dark:hover:bg-zinc-900/40"
}`}
style={{
transform: `translateY(${vRow.start}px)`,
width: totalWidth,
minHeight: MIN_ROW,
maxHeight: MAX_ROW,
}}
>
{row.getVisibleCells().map((cell, colIndex) => {
const isFirst = colIndex === 0;
const isFocusedCell =
isFocusedRow && focus.col === colIndex;
return (
<div
key={cell.id}
role="gridcell"
aria-colindex={colIndex + 1}
tabIndex={isFocusedCell ? 0 : -1}
className={`flex shrink-0 items-center overflow-hidden border-r border-zinc-100 px-2 py-1.5 text-sm text-zinc-800 dark:border-zinc-900 dark:text-zinc-100 ${
isFirst
? selected
? "bg-indigo-50 dark:bg-indigo-950/40"
: isFocusedRow
? "bg-zinc-50 dark:bg-zinc-900/60"
: "bg-white dark:bg-zinc-950"
: ""
} ${
isFocusedCell
? "ring-2 ring-inset ring-indigo-500"
: ""
}`}
style={{
width: cell.column.getSize(),
minHeight: MIN_ROW,
maxHeight: MAX_ROW,
...(isFirst ? stickyFirst(false) : null),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
```
---
## 사용 예 & Suspense 경계
`use(promise)`는 Promise가 pending이면 Suspense로 전파됩니다. 쿼리 변경 시 전체 폴백이 거슬리면 위처럼 `aria-busy` + 이전 데이터 유지 패턴으로 바꾸거나, `useTransition`으로 Promise 교체를 감싸면 됩니다. 데모에서는 첫 로드용 경계를 둡니다.
```tsx
// App.tsx
import { Suspense } from "react";
import { VirtualDataTable } from "./components/VirtualDataTable";
export default function App() {
return (
<main className="mx-auto max-w-[1400px] p-6">
<h1 className="mb-4 text-xl font-semibold tracking-tight">
Virtual Data Table (10k)
</h1>
<Suspense
fallback={
<div className="flex h-96 items-center justify-center rounded-xl border border-dashed border-zinc-300 text-sm text-zinc-500">
데이터 로딩…
</div>
}
>
<VirtualDataTable />
</Suspense>
</main>
);
}
```
---
## Tailwind 4 메모
- 유틸 클래스명은 v3와 호환됩니다. 엔트리 CSS만 다음처럼 두면 됩니다.
```css
@import "tailwindcss";
```
- sticky 헤더/첫 컬럼은 `position: sticky` + 스크롤 컨테이너(`overflow-auto`)가 **조상**이어야 합니다. 위 구조가 그 조건을 만족합니다.
- 첫 컬럼 sticky 시 스크롤 중 배경 비침을 막기 위해 셀에 불투명(또는 거의 불투명) 배경을 줍니다.
---
## 요구사항 매핑
| 요구 | 구현 |
|---|---|
| (1) 가변 row height 36–72 | `measureElement` + 셀 `minHeight`/`maxHeight`/`line-clamp-3` |
| (2) sticky header + 첫 컬럼 | header `top-0 sticky`, col0 `left-0 sticky` + z-index 계층 |
| (3) 키보드 + ARIA grid | `role="grid|row|columnheader|gridcell"`, `aria-rowindex/colindex/sort/selected`, 방향키·PgUp/PgDn·Home/End·Space |
| (4) 정렬·필터 서버 + 300ms | `manualSorting`/`manualFiltering` + `useDebouncedFilters(300)` + `use(fetchPeople)` |
| (5) 다중 선택 + Shift 범위 | `rowSelection` + `lastAnchorRef` + click/Space |
---
## 운영 시 보완 포인트
1. **실제 API**: `fetchPeople`을 `fetch(/api/people?…)`로 교체하고, 캐시 키를 URL로 두면 됩니다. React 19 `use`와 맞추려면 모듈 스코프/맵 캐시 또는 라우터 로더 Promise를 넘기면 됩니다.
2. **Firefox 측정**: TanStack 권장대로 Firefox에서는 `measureElement`를 끄고 `estimateSize` + 명시 height를 쓰는 분기를 넣었습니다.
3. **포커스 복원**: 필터 후 행 수가 줄면 `focus.row`를 clamp 하는 effect를 추가하는 것이 안전합니다.
4. **선택 행 ID 안정성**: `getRowId: r => r.id`로 필터·정렬 후에도 selection이 id 기준으로 유지됩니다.
5. **대량 DOM**: 컬럼 12 × overscan 행만 마운트되므로 1만 행에서도 스크롤이 가볍습니다. sticky 첫 컬럼 + 가로 스크롤 조합에서 페인트를 더 줄이려면 첫 컬럼만 별도 레이어(`will-change: transform` 남용 금지)로 두는 최적화도 가능합니다.
이 구성이 React 19 + Tailwind 4 + TS 환경에서 요구 5가지를 한 컴포넌트 흐름으로 충족합니다.채점 근거TanStack Virtual/Table 선택근거+react-window/virtuoso 비교, sticky 첫컬럼+ARIA grid(role/rowindex/colindex/gridcell)+키보드(Arrow/PgUp/PgDn)+Shift범위선택(anchor ref) 5요건 전부 구현확인. React19 기능(use/useTransition/useDeferredValue) 사용여부를 표로 근거제시하며 미사용기능도 이유와 함께 선택적 배제