본문으로 건너뛰기
AXyNowAX IS NOW
코드·개발문항 2React 19 + Tailwind 4 — 가상 스크롤 테이블 (1만 행)비공개

React 가상 스크롤 테이블

비공개 문항 — 학습 오염 방지. 측정 영상에서 응답 원문과 함께 본문 공개.

기대 포맷컴포넌트 + 성능 최적화 근거 + a11y

사전 정의 함정2건 — 발동 모델에 ⚠ 표시

모델별 응답 · 채점

32개 모델. 점수만 보임 — 모델명 클릭 시 raw·근거·cross 펼침.

모델
정확성의도 파악신중함한국 맥락짜임새avg
Claude Opus 4.8Anthropic
1001001006010096
아래에 라이브러리 선택 근거 → 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).

2026-05-29 00:00
GPT-5.5OpenAI
808080608078
아래 예시는 **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.

2026-05-25 14:47
MiniMax M3Minimax
889592809691

채점 근거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 계층으로 정확히 해결

2026-06-02 02:36
Gemini 3.1 ProGoogle
80100806010088
요구사항을 충족하기 위해 **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를 논블로킹 처리합니다.

2026-05-25 14:14
Claude Sonnet 4.6Anthropic
100100806010094
# 가상 스크롤 테이블 — 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급 완성도

2026-05-27 05:56
Gemini 3.5 FlashGoogle
808060808078
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용)

2026-05-24 03:32
Nemotron 3 Ultra 550BNVIDIA
608062758874
## 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 재현(설득력 高·미실행).

2026-06-05 01:21
DeepSeek V4 FlashDeepSeek
808080708079
## 가상 스크롤 테이블 컴포넌트 설계 (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 훅. 완전

2026-05-26 14:28
Qwen 3.7 PlusAlibaba
808280648279
요구사항을 완벽하게 충족하기 위해 **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 범위선택 망라

2026-06-03 14:50
DeepSeek V4 ProDeepSeek
808080708079
아래 코드는 요구사항을 모두 충족하는 가상 스크롤 데이터 그리드 컴포넌트의 예시입니다. **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과 달리 정상)

2026-05-26 15:45
Mimo V2.5 ProXiaomi
808080708079
# 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 동급, 과도 아님)

2026-05-27 06:16
GPT-5.4 MiniOpenAI
808080808080
아래 조합을 추천합니다.

- **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

2026-05-24 03:45
Kimi K2.6Moonshot
808080808080
아래는 요구사항을 충족하는 **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 인지

2026-05-24 04:37
GLM 5.1Z.ai
808080808080
## 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. 견고

2026-05-24 04:58
Gemma 4 12BGoogle
787680608077
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) 직하.

2026-06-04 06:22
Qwen 3.7 MaxAlibaba
808080808080
# 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

2026-05-27 01:19
Grok 4.3xAI
808080708079
**권장 조합: 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

2026-05-26 14:09
Mistral Small 4Mistral
807070707574
# 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 길고 일부 산만

2026-05-26 14:29
Step 3.7 FlashStepFun
528855708872
### 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 }}
                    >
                      &nbsp;
                    </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 훅 오용

2026-06-02 06:24
Qwen 3.6 27BAlibaba
807070707574


# 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 과도

2026-05-26 14:53
Gemini 3.1 Flash LiteGoogle
808080708079
요구하신 조건은 대규모 데이터 처리와 복잡한 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 간결

2026-05-26 14:03
Gemma 4 26B A4BGoogle
808080708079
이 요구사항은 매우 높은 수준의 성능과 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. 완전

2026-05-26 14:15
Qwen 3.6 35B A3BAlibaba
807070707073
# 가상 스크롤 테이블 컴포넌트 — 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·구조 효율 감점

2026-05-26 14:41
Solar Pro 3Upstage
507040408061
아래는 **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 환각.

2026-05-28 23:09
Qwen 3.5 9BAlibaba
526860707866


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 제약들이 깨짐.

2026-06-04 02:30
Gemma 4 31BGoogle
808060608076
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)

2026-05-27 01:36
EXAONE 4.5 33BLG AI
406040407053
### 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 환각.

2026-05-29 00:33
HyperCLOVAX SEED Think 32BNaver
206040604040


다음은 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 언급.

2026-05-29 15:05
Gemma 4 E2BGoogle
425040424444
요청하신 요구사항은 고성능, 복잡한 상호작용(가변 높이, 키보드 네비게이션, 다중 선택, 디바운스 필터링)을 모두 요구하는 고급 데이터 그리드 컴포넌트입니다.

이러한 요구사항을 충족시키기 위해, **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 가변높이 부적합

2026-06-01 12:33
Kanana 2 30B-A3B ThinkingKakao
306040406047
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 사용 심각 오류.

2026-05-28 08:06
LFM2.5 8B-A1BLiquid AI
445340434646

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

2026-06-01 14:10
HyperCLOVAX SEED 1.5BNaver
294129313132
**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

2026-06-01 11:24