프로젝트에서 실제로 사용 중인 Data Table 아키텍처와 노트북 화면에서 페이지네이션이 잘리지 않게 한 경험을 공유합니다.
웹 애플리케이션에서 테이블은 거의 필수적인 UI 요소입니다. 이번 글에서는 Next.js 14(App Router), TanStack Table, Tailwind CSS를 활용해 구현한 DataTable 컴포넌트의 구조와, 반응형 페이지 사이즈, 레이아웃 최적화를 어떻게 적용했는지 정리해봅니다.
우리 프로젝트에는 DataTable과 DataTableV2 두 가지 테이블 컴포넌트가 있습니다.
useReactTable 훅을 활용type 기반으로 정의 (text, badge, input, actions, checkbox, status)사용자가 리스트 뷰와 그리드 뷰를 전환할 수 있습니다.
viewMode?: 'list' | 'grid'
selectedRows, onSelectedRowsChange로 상태 제어리스트 뷰에서 특정 셀을 클릭해 인라인 편집 후, 확인 모달로 저장 여부를 물어봅니다.
trim() 기준으로 변경 여부 판단data-no-row-click로 편집 셀 클릭 시 행 클릭 이벤트 차단renderToolbar: 검색, 정렬, 필터, 뷰 전환 버튼 등showTotalCount, secondaryCount, tertiaryCount: Matched/Unmatched 등 상태별 카운트 표시onTotalCount, onSecondaryCount 등으로 클릭 시 필터 연동노트북처럼 세로 공간이 제한된 환경에서는 페이지당 행 수를 화면 높이에 맞게 조절하는 것이 중요합니다. 그래서 useDynamicPageSize 훅을 만들어 사용합니다.
// Viewport 높이 = window.innerHeight - offsetTop - offsetBottom
// availableHeight 구간별로 page size 매핑
List 모드: 행 높이(~48px) 기준
< 500px: 8< 700px: 8 (MacBook M2 Pro 등)< 900px: 15< 1100px: 15< 1300px: 20>= 1300px: 25Grid 모드: 카드 높이(~400px) 기준, 열 수 × 행 수로 계산
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 이벤트에 디바운스를 걸어 불필요한 재계산을 줄입니다.노트북에서 테이블 영역이 크면, 하단 페이지네이션이 viewport 아래로 잘려서 보이지 않는 문제가 있었습니다. 이를 해결하기 위해 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: 항상 표시되고 줄어들지 않도록 합니다.// 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>
헤더는 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>
);
})}
페이지 번호 표시 방식은 다음과 같습니다.
1 2 3 4 ... 47 48 49 501 2 3 ... 5 6 7 8 ... 47 48 49 501 2 3 4 ... 47 48 49 50직접 페이지 번호를 입력해 이동하는 기능도 지원합니다.
이 구조를 통해 다양한 화면 크기에서도 테이블과 페이지네이션이 자연스럽게 동작하도록 만들었습니다.
// 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;
}
// 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>
);
// 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>
// 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>
);
// 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>;
// 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, ...);