Next.js + TanStack Table + Tailwind로 만드는 반응형 Data Table 컴포넌트

IT쿠키·2026년 2월 27일
post-thumbnail

프로젝트에서 실제로 사용 중인 Data Table 아키텍처와 노트북 화면에서 페이지네이션이 잘리지 않게 한 경험을 공유합니다.


1. 개요

웹 애플리케이션에서 테이블은 거의 필수적인 UI 요소입니다. 이번 글에서는 Next.js 14(App Router), TanStack Table, Tailwind CSS를 활용해 구현한 DataTable 컴포넌트의 구조와, 반응형 페이지 사이즈, 레이아웃 최적화를 어떻게 적용했는지 정리해봅니다.

스택

  • Next.js 14 (App Router)
  • @tanstack/react-table
  • Tailwind CSS
  • TypeScript

2. 컴포넌트 아키텍처

우리 프로젝트에는 DataTableDataTableV2 두 가지 테이블 컴포넌트가 있습니다.

DataTable (TanStack Table 기반)

  • List View: 전통적인 테이블 형태 (thead + tbody)
  • Grid View: 카드 형태 그리드 (4열)
  • 정렬, 필터링, 컬럼 리사이징, Infinite Scroll 지원
  • TanStack Table의 useReactTable 훅을 활용

DataTableV2 (선언적 컬럼 API)

  • 컬럼을 type 기반으로 정의 (text, badge, input, actions, checkbox, status)
  • DataTable보다 선언적이고 설정이 단순함
  • Export 목록, Trash, Storage 등 비교적 단순한 테이블에 사용

3. 핵심 기능

3.1 List / Grid 뷰 전환

사용자가 리스트 뷰와 그리드 뷰를 전환할 수 있습니다.

viewMode?: 'list' | 'grid'
  • List View: 행 클릭, 정렬, 인라인 편집 등 지원
  • Grid View: 카드 형태, Infinite Scroll 옵션 지원

3.2 행 선택 (Row Selection)

  • 체크박스로 여러 행 선택
  • 선택된 행에 따라 액션 바(삭제 등) 노출
  • selectedRows, onSelectedRowsChange로 상태 제어

3.3 인라인 편집 (EditableTableCell)

리스트 뷰에서 특정 셀을 클릭해 인라인 편집 후, 확인 모달로 저장 여부를 물어봅니다.

  • trim() 기준으로 변경 여부 판단
  • Enter/Escape/Blur로 편집 모드 해제
  • data-no-row-click로 편집 셀 클릭 시 행 클릭 이벤트 차단

3.4 툴바 & 헤더

  • renderToolbar: 검색, 정렬, 필터, 뷰 전환 버튼 등
  • showTotalCount, secondaryCount, tertiaryCount: Matched/Unmatched 등 상태별 카운트 표시
  • onTotalCount, onSecondaryCount 등으로 클릭 시 필터 연동

4. 반응형 페이지 사이즈 (useDynamicPageSize)

노트북처럼 세로 공간이 제한된 환경에서는 페이지당 행 수를 화면 높이에 맞게 조절하는 것이 중요합니다. 그래서 useDynamicPageSize 훅을 만들어 사용합니다.

4.1 동작 방식

// Viewport 높이 = window.innerHeight - offsetTop - offsetBottom
// availableHeight 구간별로 page size 매핑
  • List 모드: 행 높이(~48px) 기준

    • < 500px: 8
    • < 700px: 8 (MacBook M2 Pro 등)
    • < 900px: 15
    • < 1100px: 15
    • < 1300px: 20
    • >= 1300px: 25
  • Grid 모드: 카드 높이(~400px) 기준, 열 수 × 행 수로 계산

4.2 사용 예시

const dynamicPageSize = useDynamicPageSize({
  debounceMs: 150,
  viewMode: currentView, // 'list' | 'grid'
  gridColumns: 4,
});

const { page, size, setSize, ... } = usePagination('key', { size: dynamicPageSize });

useEffect(() => {
  setSize(dynamicPageSize);
}, [dynamicPageSize, setSize]);
  • window.resize 이벤트에 디바운스를 걸어 불필요한 재계산을 줄입니다.
  • 뷰 모드가 바뀔 때마다 page size도 다시 계산됩니다.

5. 레이아웃: 페이지네이션이 잘리지 않도록

노트북에서 테이블 영역이 크면, 하단 페이지네이션이 viewport 아래로 잘려서 보이지 않는 문제가 있었습니다. 이를 해결하기 위해 flex 레이아웃높이 제한을 조정했습니다.

5.1 Flex 체인 구성

Layout (flex-1 min-h-0)
  └─ Content Wrapper (flex-1 min-h-0 flex flex-col overflow-hidden)
       └─ Table Wrapper (flex-1 min-h-0)
            └─ DataTable (flex flex-col overflow-hidden min-h-0)
                 ├─ Header (flex-shrink-0)
                 ├─ Table Body (flex-1 min-h-0 overflow-auto max-h-[calc(100vh-360px)])
                 └─ Pagination (mt-auto flex-shrink-0)
  • flex-1 min-h-0: 부모가 남긴 공간을 차지하고, 필요하면 축소될 수 있게 합니다.
  • overflow-hidden: 넘치는 부분을 숨기고, 내부 스크롤 영역만 활성화합니다.
  • 테이블 본문에 max-h-[calc(100vh-360px)]: 상단 헤더·스텝·툴바·페이지네이션을 제외한 영역만 스크롤되도록 합니다.
  • 페이지네이션에 flex-shrink-0: 항상 표시되고 줄어들지 않도록 합니다.

5.2 적용 패턴

// Layout
<div className="flex-1 min-h-0 flex flex-col gap-6 overflow-hidden">
  <TableWrapper className="flex-1 min-h-0 flex flex-col overflow-hidden">
    <DataTable
      containerClassName="w-full h-full min-h-0"
      ...
    />
  </TableWrapper>
</div>

6. 테이블 코너 라운드 처리

헤더는 rounded-tl-lg, rounded-tr-lg로 상단 모서리를, 마지막 행의 첫/마지막 셀은 rounded-bl-lg, rounded-br-lg로 하단 모서리를 둥글게 처리했습니다.

// DataTableListView.tsx - 마지막 행 셀 렌더링
{cells.map((cell, cellIndex) => {
  const isLastRow = rowIndex === rows.length - 1;
  const isFirstCell = cellIndex === 0;
  const isLastCell = cellIndex === cells.length - 1;

  return (
    <TableCell
      key={cell.id}
      className={cn(
        isSelected && '!bg-[#E3F2FD]',
        isLastRow && isFirstCell && 'border-l rounded-bl-lg',
        isLastRow && isLastCell && 'border-r rounded-br-lg'
      )}
    >
      {flexRender(cell.column.columnDef.cell, cell.getContext())}
    </TableCell>
  );
})}

7. 페이지네이션 컴포넌트

페이지 번호 표시 방식은 다음과 같습니다.

  • 현재 페이지가 앞쪽일 때: 1 2 3 4 ... 47 48 49 50
  • 중간일 때: 1 2 3 ... 5 6 7 8 ... 47 48 49 50
  • 뒤쪽일 때: 1 2 3 4 ... 47 48 49 50

직접 페이지 번호를 입력해 이동하는 기능도 지원합니다.


8. 정리

  • DataTable / DataTableV2 두 종류로 복잡도에 따라 선택
  • useDynamicPageSize로 화면 높이에 맞는 페이지 사이즈 자동 조정
  • flex + min-h-0 + overflow-hidden로 페이지네이션 노출 보장
  • EditableTableCell로 인라인 편집 + 확인 모달 패턴 적용

이 구조를 통해 다양한 화면 크기에서도 테이블과 페이지네이션이 자연스럽게 동작하도록 만들었습니다.


부록: 대표 소스 코드

A. useDynamicPageSize 훅 (전체)

// src/hooks/useDynamicPageSize.ts
'use client';

import { useCallback, useEffect, useState } from 'react';

const DEFAULT_OFFSET_TOP = 220;
const DEFAULT_OFFSET_BOTTOM = 140;

function getListPageSizeByBreakpoint(availableHeight: number): number {
  if (availableHeight < 500) return 8;
  if (availableHeight < 700) return 8;
  if (availableHeight < 900) return 15;
  if (availableHeight < 1100) return 15;
  if (availableHeight < 1300) return 20;
  return 25;
}

function getGridPageSizeByBreakpoint(availableHeight: number, columns: number): number {
  const CARD_ROW_HEIGHT = 400;
  const rows = Math.max(1, Math.floor(availableHeight / CARD_ROW_HEIGHT));
  return Math.max(columns, columns * rows);
}

function getPageSizeByBreakpoint(
  availableHeight: number,
  viewMode: 'list' | 'grid',
  gridColumns: number
): number {
  if (viewMode === 'grid') {
    return getGridPageSizeByBreakpoint(availableHeight, gridColumns);
  }
  return getListPageSizeByBreakpoint(availableHeight);
}

export function useDynamicPageSize(options = {}) {
  const {
    offsetTop = DEFAULT_OFFSET_TOP,
    offsetBottom = DEFAULT_OFFSET_BOTTOM,
    minSize = 8,
    maxSize = 30,
    debounceMs = 150,
    viewMode = 'list',
    gridColumns = 4,
  } = options;

  const calcSize = useCallback(() => {
    if (typeof window === 'undefined') return minSize;
    const available = window.innerHeight - offsetTop - offsetBottom;
    const size = getPageSizeByBreakpoint(available, viewMode, gridColumns);
    return Math.min(maxSize, Math.max(minSize, size));
  }, [offsetTop, offsetBottom, minSize, maxSize, viewMode, gridColumns]);

  const [pageSize, setPageSize] = useState(() => {
    if (typeof window === 'undefined') return minSize;
    const available = window.innerHeight - offsetTop - offsetBottom;
    const size = getPageSizeByBreakpoint(available, viewMode, gridColumns);
    return Math.min(maxSize, Math.max(minSize, size));
  });

  useEffect(() => {
    setPageSize(calcSize());
    let timeoutId = null;
    const handleResize = () => {
      if (timeoutId) clearTimeout(timeoutId);
      timeoutId = setTimeout(() => {
        setPageSize(calcSize());
        timeoutId = null;
      }, debounceMs);
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
      if (timeoutId) clearTimeout(timeoutId);
    };
  }, [calcSize, debounceMs]);

  useEffect(() => {
    setPageSize(calcSize());
  }, [viewMode, gridColumns, calcSize]);

  return pageSize;
}

B. DataTable 컴포넌트 (렌더링 부분)

// DataTable.tsx - return 부분
return (
  <div className={cn(containerClassName, 'flex flex-col gap-3 overflow-hidden min-h-0')}>
    <DataTableHeader {...headerProps} />

    <div className="rounded-lg scrollbar-hidden flex-1 min-h-0 overflow-auto max-h-[calc(100vh-360px)]">
      {isLoading ? (
        <SkeletonTable rows={pageSize} columns={columns.length} />
      ) : (
        <div key={viewMode} className="animate-datatable-switch will-change-transform h-full">
          {viewMode === 'grid' ? (
            <DataTableGridView {...gridProps} />
          ) : (
            <div className="overflow-visible h-full">
              <DataTableListView
                table={table}
                columns={columns}
                enableSorting={enableSorting}
                onRowClick={onRowClick}
                emptyMessage={emptyMessage}
                selectedRowId={selectedRowId}
                onEdit={onEdit}
                onDelete={onDelete}
              />
            </div>
          )}
        </div>
      )}
    </div>

    {!(viewMode === 'grid' && enableInfiniteScroll) && (
      <DataTablePagination
        isLoadedData={isLoadedData}
        currentPage={page ?? 0}
        totalCount={totalCount ?? 0}
        pageSize={pageSize}
        onPageChange={handlePageChange}
        className="mt-auto flex-shrink-0"
      />
    )}
  </div>
);

C. EditableTableCell (핵심 로직)

// EditableTableCell.tsx - 확인 모달 + Enter/Blur 처리
const trySave = useCallback(() => {
  if (!hasChanged) {
    exitEdit();
    return;
  }
  openModal({
    title: t('data-table.inline-edit-confirm.title'),
    content: t('data-table.inline-edit-confirm.message'),
    closeOnlyByAction: true,
    footer: (
      <div className="flex gap-2 justify-end w-full">
        <button onClick={() => { closeModal(); exitEdit(); }}>취소</button>
        <button onClick={async () => {
          closeModal();
          await onSave({ rowId, fieldKey, value: trimmedEdit });
          exitEdit();
        }}>적용</button>
      </div>
    ),
  });
}, [hasChanged, exitEdit, openModal, closeModal, onSave, rowId, fieldKey, trimmedEdit, t]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
  if (!isEditing) return;
  if (e.key === 'Escape') { e.preventDefault(); exitEdit(); }
  if (e.key === 'Enter') { e.preventDefault(); trySave(); }
}, [isEditing, exitEdit, trySave]);

const handleBlur = useCallback(() => {
  if (!isEditing) return;
  hasChanged ? trySave() : exitEdit();
}, [isEditing, hasChanged, trySave, exitEdit]);

// 렌더링 시 data-no-row-click으로 행 클릭 이벤트 차단
<div data-no-row-click onMouseDown={stopCellPropagation}>
  <input
    ref={inputRef}
    value={editValue}
    onChange={e => setEditValue(e.target.value)}
    onKeyDown={handleKeyDown}
    onBlur={handleBlur}
  />
</div>

D. Layout 적용 예시 (flex 체인)

// PhotoAnalysisResultsTable.tsx - 테이블 래퍼
return (
  <div className="w-full flex-1 min-h-0 flex flex-col overflow-hidden">
    <div className="bg-white rounded-lg flex-1 min-h-0 flex flex-col overflow-hidden">
      <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
        <DataTable
          columns={columns}
          data={data}
          page={page}
          totalCount={totalCount}
          pageSize={pageSize}
          onPageChange={onPageChange}
          containerClassName="w-full h-full min-h-0"
          enableRowSelection
          showTotalCount
          // ...
        />
      </div>
    </div>
  </div>
);

E. DataTableV2 컬럼 타입 정의

// DataTableV2/types.ts
export type DataTableV2ColumnSize = 64 | 80 | 96 | 124 | 172 | 224 | 'fill' | 'checkbox';

export interface DataTableV2TextColumn<TData> {
  type: 'text';
  id: string;
  header?: string;
  accessor?: keyof TData | ((row: TData) => string | number | null);
  formatter?: (value: unknown, row: TData) => string | number;
}

export interface DataTableV2BadgeColumn<TData> {
  type: 'badge';
  id: string;
  badge?: (row: TData) => { label: string | number; color?: string };
}

export interface DataTableV2InputColumn<TData> {
  type: 'input';
  id: string;
  placeholder?: string;
  disabled?: boolean | ((row: TData) => boolean);
}

export interface DataTableV2ActionsColumn<TData> {
  type: 'actions';
  id: string;
  actions: DataTableV2Action<TData>[] | ((row: TData) => DataTableV2Action<TData>[]);
}

export type DataTableV2Column<TData> =
  | DataTableV2TextColumn<TData>
  | DataTableV2BadgeColumn<TData>
  | DataTableV2InputColumn<TData>
  | DataTableV2ActionsColumn<TData>
  | DataTableV2CheckboxColumn<TData>
  | DataTableV2StatusColumn<TData>;

F. useDynamicPageSize + usePagination 연동

// ReviewLayout.tsx 예시
const [currentView, setCurrentView] = useState<'list' | 'grid'>('list');

const dynamicPageSize = useDynamicPageSize({
  debounceMs: 150,
  viewMode: currentView,
  gridColumns: 4,
});

const { page, size, totalCount, setPage, setSize, setTotalCount } =
  usePagination('review-layout', { size: dynamicPageSize });

useEffect(() => {
  setSize(dynamicPageSize);
}, [dynamicPageSize, setSize]);

// API 호출 시 size 사용
const { data } = useGetMugshotReviewData(page, size, sortOrder, projectId, ...);
profile
IT 삶을 사는 쿠키

0개의 댓글