shadcn/ui : https://ui.shadcn.com/docs/components/data-table
소스 : https://github.dev/braverokmc79/nextjs-shadcn-app
???? 디렉토리 구조
src/ ├── app/ │ ├── api/ │ │ ├── payments/ │ │ │ ├── route.tsx │ ├── payments/ │ │ ├── components/ │ │ │ ├── payments-data-table-column-header.tsx │ │ │ ├── payments-data-table-columns.tsx │ │ │ ├── payments-data-table.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── page.tsx ├── components/ │ ├── data-table-pagination.tsx
1. 레이아웃 (src/app/api/payments/layout.tsx)
import Providers from "../provders";
export const metadata = {
title: "shadcn table data",
description: "Using React Query with Next.js 15 App Router",
};
export default function PaymentsLayout({ children }: { children: React.ReactNode }) {
return (
<Providers>
{children}
</Providers>
);
}
2. API 엔드포인트 (src/app/api/payments/route.tsx)
import { NextResponse } from "next/server";
const payments = [
{ id: 1, firstName: "1.Colin", lastName: "Murray", teamName: "alpha", isTeamLeader: true, avatar: "/images/cm.jpg" },
{ id: 2, firstName: "2.Tom", lastName: "Phillips", teamName: "alpha", isTeamLeader: false },
{ id: 3, firstName: "3.Liam", lastName: "Fuentes", teamName: "alpha", isTeamLeader: false },
{ id: 4, firstName: "4.Tina", lastName: "Fey", teamName: "canary", isTeamLeader: true, avatar: "/images/tf.jpg" },
{ id: 5, firstName: "5.Katie", lastName: "Johnson", teamName: "canary", isTeamLeader: false },
{ id: 6, firstName: "6.Tina", lastName: "Jones", teamName: "canary", isTeamLeader: false },
{ id: 7, firstName: "7.Amy", lastName: "Adams", teamName: "delta", isTeamLeader: true },
{ id: 8, firstName: "8.Ryan", lastName: "Lopez", teamName: "delta", isTeamLeader: false, avatar: "/images/rl.jpg" },
{ id: 9, firstName: "9.Jenny", lastName: "Jones", teamName: "delta", isTeamLeader: false },
{ id: 10, firstName: "10.Colin", lastName: "Murray", teamName: "alpha", isTeamLeader: true, avatar: "/images/cm.jpg" },
{ id: 11, firstName: "11.Tom", lastName: "Phillips", teamName: "alpha", isTeamLeader: false },
{ id: 12, firstName: "12.Liam", lastName: "Fuentes", teamName: "alpha", isTeamLeader: false },
{ id: 13, firstName: "13.Tina", lastName: "Fey", teamName: "canary", isTeamLeader: true, avatar: "/images/tf.jpg" },
{ id: 14, firstName: "14.Katie", lastName: "Johnson", teamName: "canary", isTeamLeader: false },
{ id: 15, firstName: "15.Tina", lastName: "Jones", teamName: "canary", isTeamLeader: false },
{ id: 16, firstName: "16.Amy", lastName: "Adams", teamName: "delta", isTeamLeader: true },
{ id: 17, firstName: "17.Ryan", lastName: "Lopez", teamName: "delta", isTeamLeader: false, avatar: "/images/rl.jpg" },
{ id: 18, firstName: "18.Jenny", lastName: "Jones", teamName: "delta", isTeamLeader: false }
];
export const GET = async (request: Request) => {
const { searchParams } = new URL(request.url);
// ✅ `page`와 `pageSize`를 URL에서 가져오기
const page = Number(searchParams.get("page")) || 1;
const pageSize = Number(searchParams.get("pageSize")) || 5;
const searchType = searchParams.get("searchType") || "";
const keyword = searchParams.get("keyword") || "";
const filteredEmployees = payments.filter((payment) => {
if (searchType === "firstName") {
return payment.firstName.toLowerCase().includes(keyword.toLowerCase());
}
if (searchType === "teamName") {
return payment.teamName.toLowerCase().includes(keyword.toLowerCase());
}
if (searchType === "all") {
return (
payment.firstName.toLowerCase().includes(keyword.toLowerCase()) ||
payment.teamName.toLowerCase().includes(keyword.toLowerCase())
);
}
return true; // 기본적으로 모든 직원 포함
});
console.log("filteredEmployees :",filteredEmployees);
// ✅ 데이터 슬라이싱 (페이지네이션 적용)
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedData = filteredEmployees.slice(startIndex, endIndex);
// console.log("GET page 파라미터 ",searchParams.get("page"), searchType, keyword);
// console.log("GET data 페이지와 페이지 사이즈: ",page, pageSize);
// console.log("GET paginatedData: ",paginatedData);
return NextResponse.json({
status: 200,
data: paginatedData,
message: "success",
page,
pageSize,
totalCount: filteredEmployees.length
});
};
- GET 요청을 처리하여 더미데이터 payments 데이터를 반환.
- page, pageSize, keyword 쿼리 매개변수를 받아 필터링 및 페이징 처리.
3.페이지 컴포넌트 (src/app/payments/page.tsx)
"use client";
import { useSearchParams } from "next/navigation";
import { PaymentsDataTableColumns } from "./components/payments-data-table-columns";
import { PaymentsDataTable } from "./components/payments-data-table";
import { useState, useCallback } from "react";
import { toast } from "@/hooks/custom-use-toast";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebounce } from "@/hooks/useDebounce";
const fetchPayments= async ({ queryKey }: { queryKey: any }) => {
const [, page, pageSize, searchType, keyword] = queryKey;
const queryString = `/api/payments?page=${page}&pageSize=${pageSize}&searchType=${searchType}&keyword=${keyword}`;
const response = await fetch(queryString);
if (!response.ok) {
throw new Error("데이터를 불러오는 중 오류가 발생했습니다.");
}
return response.json();
};
export default function PaymentsPage() {
const searchParams = useSearchParams();
const pageFromParams = Number(searchParams.get("page")) || 1;
const pageSizeFromParams = Number(searchParams.get("pageSize")) || 5;
const [page, setPage] = useState(pageFromParams);
const [pageSize, setPageSize] = useState(pageSizeFromParams);
const [searchType, setSearchType] = useState("");
const [keyword, setKeyword] = useState("");
const [tempKeyword, setTempKeyword] = useState(""); // ???? 검색 입력을 위한 임시 상태
const debouncedKeyword = useDebounce(tempKeyword, 300); // 300ms 디바운스 적용
const queryClient = useQueryClient();
// ✅ React Query로 데이터 가져오기
const { data, error, isFetching } = useQuery({
queryKey: ["employees", page, pageSize, searchType, debouncedKeyword],
queryFn: fetchPayments,
staleTime: 15000, // 15초 동안 캐시 유지
refetchOnWindowFocus: false, // 포커스 시 자동 리패치 방지
});
// ???? 검색 입력 처리 (Enter 키로만 검색 실행)
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value); // keyword 상태 업데이트 (검색 실행)
setTempKeyword(e.target.value); // 입력 값은 tempKeyword에만 저장
};
// ???? Enter 키를 눌렀을 때 검색 실행
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearch();
}
};
// ???? 검색 버튼 클릭 시 실행 (검색 타입이 없을 경우 알림)
const handleSearch = useCallback(() => {
if (!searchType) {
toast({
variant: "default",
position: "top",
description: "✅ 검색 타입을 선택해 주세요.",
});
return;
}
setKeyword(tempKeyword); // 사용자가 입력한 검색어 반영
setPage(1);
queryClient.invalidateQueries({ queryKey: ["employees", page, pageSize, searchType, keyword] });
}, [searchType, tempKeyword, queryClient]);
//⭕ 페이징
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const handlePageSizeChange = useCallback((newPageSize: number) => {
setPageSize(newPageSize);
setPage(1);
}, []);
return (
<div className="container mx-auto py-10">
<PaymentsDataTable
isLoading={isFetching}
error={error}
paymentsColumns={PaymentsDataTableColumns}
data={data?.data || []}
page={page}
pageSize={pageSize}
totalCount={data?.totalCount || 0}
setPage={handlePageChange}
setPageSize={handlePageSizeChange}
searchType={searchType}
keyword={keyword}
setSearchType={setSearchType}
setKeyword={setKeyword}
handleSearch={handleSearch}
handleKeywordChange={handleKeywordChange}
handleKeyDown={handleKeyDown}
/>
</div>
);
}
- useQuery를 사용하여 서버에서 데이터를 가져옴.
- PaymentsDataTable에 데이터를 전달.
- page, pageSize, keyword 상태를 관리하여 검색 및 페이징 처리.
4.로딩 UI (src/app/payments/loading.tsx)
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import React from 'react';
const PaymentsLoading = () => {
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>직원</CardTitle>
</CardHeader>
<CardContent className='grid grid-cols-[60px_1fr_1fr_1fr_1fr] gap-4 items-center'>
{[...Array(4)].map((_, index) => (
<React.Fragment key={index}>
<Skeleton className='size-10 rounded-full ' />
<Skeleton className='h-8 w-full ' />
<Skeleton className='h-8 w-full ' />
<Skeleton className='h-8 w-full ' />
<Skeleton className='h-8 w-full' />
</React.Fragment>
))}
</CardContent>
</Card>
);
};
export default PaymentsLoading;- Skeleton 컴포넌트를 활용하여 로딩 상태 표시.
- Card 내부에서 직원 리스트의 스켈레톤 UI 렌더링.
5. PaymentsDataTable 컴포넌트 설명 (src/app/payments/components/payments-data-table.tsx)
1. 개요
이 컴포넌트는 Next.js 15 + shadcn/ui + @tanstack/react-table을 이용하여 테이블을 구현한 것입니다. 페이징, 정렬, 검색, 컬럼 필터링 기능을 제공하며, 데이터 로딩 및 오류 처리까지 포함된 테이블 컴포넌트입니다.
2. 사용된 주요 라이브러리
- @tanstack/react-table: 테이블 데이터를 관리하고 렌더링하는 라이브러리
- shadcn/ui: UI 컴포넌트 라이브러리 (버튼, 입력 필드, 드롭다운 메뉴 등)
- Next.js 15: 프레임워크로 사용
3. Props 설명
interface DataTableProps<TData, TValue> {
isLoading: boolean; // 데이터 로딩 상태
error: any; // 오류 상태
paymentsColumns: ColumnDef<TData, TValue>[]; // 테이블 컬럼 정의
data: TData[]; // 테이블 데이터
page: number; // 현재 페이지
pageSize: number; // 페이지당 항목 수
totalCount: number; // 전체 데이터 개수
setPage: (page: number) => void; // 페이지 변경 핸들러
setPageSize: (pageSize: number) => void;// 페이지 사이즈 변경 핸들러
searchType: string; // 검색 유형 (전체, 성명, 팀명 등)
keyword: string; // 검색어
setSearchType: (searchType: string) => void; // 검색 유형 변경 핸들러
setKeyword: (keyword: string) => void; // 검색어 변경 핸들러
handleSearch: () => void; // 검색 실행 핸들러
handleKeywordChange: (e: React.ChangeEvent<HTMLInputElement>) => void; // 검색어 입력 핸들러
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void; // 검색 입력 키 이벤트 핸들러
}4. 주요 기능 설명
1) 테이블 상태 관리
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});- 정렬, 컬럼 필터, 컬럼 가시성, 행 선택 상태를 관리합니다.
2) useReactTable를 이용한 테이블 설정
const table = useReactTable({
data,
columns: paymentsColumns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, columnVisibility, rowSelection },
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize),
});- 데이터를 테이블에 적용하고, 필터링 및 정렬 등을 처리합니다.
- manualPagination: true를 설정하여 서버 기반 페이징을 처리할 수 있도록 합니다.
3) 검색 기능
<Select onValueChange={setSearchType} value={searchType}>
<SelectTrigger>
<SelectValue placeholder="검색 타입을 선택해 주세요." />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체</SelectItem>
<SelectItem value="firstName">성명</SelectItem>
<SelectItem value="teamName">팀명</SelectItem>
</SelectContent>
</Select>
<Input
type="search"
placeholder="검색"
value={keyword}
onChange={(event) => handleKeywordChange(event)}
onKeyDown={(event) => handleKeyDown(event)}
/>
<Button onClick={handleSearch}>검색</Button>- 검색 유형을 선택하고, 검색어를 입력하여 필터링을 수행합니다.
- handleSearch를 실행하면 검색 요청을 보냅니다.
4) 컬럼 가시성 토글
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">컬럼 표시/숨기기</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table.getAllColumns().filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>- DropdownMenuCheckboxItem을 사용해 컬럼 표시 여부를 제어합니다.
5) 테이블 본문 렌더링
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={paymentsColumns.length} className="text-center">
{error ? <span className="text-red-500">데이터를 불러오는 중 오류가 발생했습니다.</span> : "데이터가 없습니다."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>- 테이블 헤더와 본문을 동적으로 생성합니다.
- flexRender를 사용하여 데이터를 렌더링합니다.
6) 페이지네이션 적용
<DataTablePagination
table={table}
page={page}
pageSize={pageSize}
totalCount={totalCount}
setPage={setPage}
setPageSize={setPageSize}
/>- 페이지네이션을 적용하여 테이블 데이터를 페이지 단위로 나눠서 보여줍니다.
전체 코드
"use client"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import PaymentsLoading from "../loading"
import { DataTablePagination } from "@/components/data-table-pagination"
interface DataTableProps<TData, TValue> {
isLoading: boolean
error: any
paymentsColumns: ColumnDef<TData, TValue>[]
data: TData[]
page: number
pageSize: number
totalCount: number
setPage: (page: number) => void
setPageSize: (pageSize: number) => void
searchType: string
keyword: string
setSearchType: (searchType: string) => void
setKeyword: (keyword: string) => void
handleSearch: () => void
handleKeywordChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function PaymentsDataTable<TData, TValue>({
isLoading,
error,
paymentsColumns,
data,
page, pageSize, totalCount,setPage,setPageSize,
searchType, keyword, setSearchType, setKeyword, handleSearch,
handleKeywordChange,handleKeyDown
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] =useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const table = useReactTable({
data,
columns: paymentsColumns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize),
})
return (
<div>
<div className="flex items-center py-4">
<div className="w-6/12 grid grid-cols-[30%_60%_1fr] gap-2">
<Select onValueChange={setSearchType} value={searchType}>
<SelectTrigger>
<SelectValue placeholder="검색 타입을 선택해 주세요." />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체</SelectItem>
<SelectItem value="firstName">성명</SelectItem>
<SelectItem value="teamName">팀명</SelectItem>
</SelectContent>
</Select>
<Input
type="search"
placeholder="검색"
value={keyword}
onChange={(event) => handleKeywordChange(event)}
onKeyDown={(event) => handleKeyDown(event)}
className="max-w-sm"
/>
<Button
variant="default"
className="ml-auto bg-blue-600 text-white hover:bg-blue-700 transition-all"
onClick={handleSearch}
>
검색
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
컬럼 표시/숨기기
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table.getAllColumns().filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-1 text-sm text-muted-foreground mb-5">
{table.getFilteredRowModel().rows.length} 중{" "}
{table.getFilteredSelectedRowModel().rows.length} 행이 선택되었습니다.
</div>
<div className="rounded-md border ">
{isLoading && (
<PaymentsLoading />
)}
{!isLoading && <Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={paymentsColumns.length} className="h-24 text-center">
{error ? <div className="text-center text-red-500">데이터를 불러오는 중 오류가 발생했습니다.</div>
:"데이터가 없습니다."
}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
}
</div>
<DataTablePagination
position="right"
table={table}
page={page}
pageSize={pageSize}
totalCount={totalCount}
setPage={setPage}
setPageSize={setPageSize}
selectText={true}
displayText={true}
totalCountText={true}
/>
</div>
)
}
결론
이 PaymentsDataTable 컴포넌트는 shadcn/ui와 tanstack/react-table을 활용하여 검색, 필터링, 정렬, 컬럼 표시 여부 조절, 페이지네이션 등의 기능을 포함하는 강력한 테이블을 구현한 것입니다. 서버 기반 페이징이 가능하도록 설계되었으며, 데이터 로딩 및 오류 처리가 포함되어 있습니다.
6.PaymentsDataTableColumnHeader 컴포넌트 설명
(src/app/payments/components/payments-data-table-column-header.tsx)
이 컴포넌트는 Next.js 15 + shadcn/ui 환경에서 @tanstack/react-table을 기반으로 하는 데이터 테이블의 열 헤더를 렌더링하는 역할을 합니다.
정렬 및 컬럼 숨기기 기능을 포함한 드롭다운 메뉴를 제공합니다.
import { Column } from "@tanstack/react-table"
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
interface PaymentsDataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function PaymentsDataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: PaymentsDataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDown />
) : column.getIsSorted() === "asc" ? (
<ArrowUp />
) : (
<ChevronsUpDown />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp className="h-3.5 w-3.5 text-muted-foreground/70" />
오름차순
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown className="h-3.5 w-3.5 text-muted-foreground/70" />
내림차순
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff className="h-3.5 w-3.5 text-muted-foreground/70" />
숨기기
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
1. 주요 Props
- column: @tanstack/react-table의 Column 객체로, 컬럼의 상태 및 동작을 제어합니다.
- title: 컬럼의 제목을 나타내는 문자열 값입니다.
- className: 추가적인 스타일을 적용할 수 있도록 HTML div의 클래스를 확장할 수 있습니다.
2. 주요 기능
정렬 기능
- column.getCanSort()이 false이면 정렬이 불가능한 컬럼이므로 단순히 제목만 표시합니다.
- 그렇지 않으면 드롭다운 메뉴를 제공하여 정렬 옵션을 제공합니다.
- 정렬 상태에 따라 오름차순(▲), 내림차순(▼), 정렬되지 않음(⇅) 아이콘을 동적으로 변경합니다.
- column.toggleSorting(false): 오름차순 정렬 수행
- column.toggleSorting(true): 내림차순 정렬 수행
컬럼 숨기기 기능
- column.toggleVisibility(false)를 호출하여 컬럼을 숨길 수 있습니다.
3. 사용된 UI 요소
- shadcn/ui 라이브러리 컴포넌트
- Button: 정렬 버튼을 렌더링
- DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger: 정렬 및 숨김 옵션을 제공하는 드롭다운 메뉴 구성
- 아이콘 (lucide-react 라이브러리)
- ArrowDown: 내림차순 정렬 아이콘
- ArrowUp: 오름차순 정렬 아이콘
- ChevronsUpDown: 정렬되지 않은 상태 아이콘
- EyeOff: 컬럼 숨기기 아이콘
4. 주요 동작 흐름
- column.getCanSort()를 확인하여 정렬 가능 여부를 판단합니다.
- 정렬 가능한 경우, 드롭다운 버튼을 생성하여 정렬 상태를 표시합니다.
- 드롭다운 메뉴에는 오름차순, 내림차순, 컬럼 숨기기 옵션이 포함됩니다.
- 사용자가 정렬을 변경하면 column.toggleSorting()을 호출하여 새로운 정렬 상태를 적용합니다.
- 사용자가 컬럼을 숨기려 하면 column.toggleVisibility(false)를 호출하여 컬럼을 숨깁니다.
이 컴포넌트는 데이터 테이블의 컬럼 헤더를 동적으로 관리할 수 있도록 설계되어 있으며, shadcn/ui와 tanstack/react-table을 활용하여 UX를 향상시키는 역할을 합니다.
7.PaymentsDataTableColumns 컴포넌트 설명
(src/app/payments/components/payments-data-table-columns.tsx)
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox"
import { PaymentsDataTableColumnHeader } from "./payments-data-table-column-header";
export type PaymentsType={
id: number | string;
firstName: string;
lastName: string;
teamName: string;
isTeamLeader: boolean;
avatar?: undefined | string;
}
export const PaymentsDataTableColumns: ColumnDef<PaymentsType>[] = [
{
accessorKey: "id",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "firstName",
header: ({ column }) => (
<PaymentsDataTableColumnHeader column={column} title="성명" />
),
},
{
accessorKey: "teamName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
팀명
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
},
{
accessorKey: "isTeamLeader",
header: "팀리더",
},
{
id: "actions",
cell: ({ row }) => {
const employee = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">메뉴 열기</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>액션</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(employee.id.toString())}
>
결제 ID 복사
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>고객 보기</DropdownMenuItem>
<DropdownMenuItem>결제 세부정보 보기</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
이 컴포넌트는 Next.js 15 + shadcn/ui 환경에서 @tanstack/react-table을 기반으로 하는 데이터 테이블의 열 헤더를 렌더링하는 역할을 합니다. 정렬 및 컬럼 숨기기 기능을 포함한 드롭다운 메뉴를 제공합니다.
PaymentsDataTableColumns 설명
1. 주요 타입 정의
type PaymentsType = {
id: number | string;
firstName: string;
lastName: string;
teamName: string;
isTeamLeader: boolean;
avatar?: undefined | string;
};이 타입은 테이블에 표시될 결제 데이터를 정의합니다.
2. 컬럼 정의 (PaymentsDataTableColumns)
(1) id 컬럼 (체크박스로 전체 선택 기능 제공)
- header: 모든 행을 선택하는 체크박스
- cell: 개별 행을 선택하는 체크박스
- 정렬 및 숨김을 비활성화 (enableSorting: false, enableHiding: false)
(2) firstName 컬럼 (사용자 이름 표시)
- PaymentsDataTableColumnHeader 컴포넌트를 활용하여 정렬 가능
(3) teamName 컬럼 (팀 이름 정렬 버튼 포함)
- Button을 사용해 정렬 기능을 제공
- 클릭 시 column.toggleSorting()을 호출하여 정렬 방향을 변경
(4) isTeamLeader 컬럼 (팀 리더 여부 표시)
- 단순 텍스트 컬럼 (추가적인 동작 없음)
(5) actions 컬럼 (액션 버튼)
- DropdownMenu를 사용하여 다양한 옵션 제공
- 결제 ID 복사
- 고객 보기
- 결제 세부정보 보기
- navigator.clipboard.writeText(employee.id.toString())를 사용해 ID 복사 기능 지원
8. Shadcn UI 데이터 테이블 공통 페이징 처리 컴포넌트
(src/components/data-table-pagination.tsx)
import { Table } from "@tanstack/react-table";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface DataTablePaginationProps<TData> {
position?: "center" | "left" | "right";
table: Table<TData>;
page: number;
pageSize: number;
totalCount: number;
setPage: (page: number) => void;
setPageSize: (pageSize: number) => void;
selectText?: boolean;
displayText?: boolean;
totalCountText?: boolean;
}
export function DataTablePagination<TData>({
position = "center",
table,
page,
pageSize,
totalCount,
setPage,
setPageSize,
selectText = false,
displayText = false,
totalCountText = false,
}: DataTablePaginationProps<TData>) {
const pageCount = Math.ceil(totalCount / pageSize);
const currentPage = page;
let classPosition = "md:justify-center";
if (position === "center") {
classPosition = "md:justify-center";
} else if (position === "left") {
classPosition = "md:justify-start";
} else if (position === "right") {
classPosition = "md:justify-end";
}
// 페이지 버튼 최적화
const getPaginationRange = () => {
const range = [];
const delta = 2;
for (let i = 1; i <= pageCount; i++) {
if (
i === 1 ||
i === pageCount ||
(i >= currentPage - delta && i <= currentPage + delta)
) {
range.push(i);
} else if (range[range.length - 1] !== "...") {
range.push("...");
}
}
return range;
};
return (
<div className={`flex items-center justify-center ${classPosition} space-x-2 py-4`}>
<div className="flex items-center justify-between px-2 ">
<div className="flex flex-col md:flex-row items-center space-y-3 md:space-y-0 md:space-x-6 lg:space-x-8">
<div
className={`flex-1 text-sm text-muted-foreground px-3 ${
!selectText && "hidden"
} `}
>
{table.getFilteredSelectedRowModel().rows.length}개 선택됨 / 총{" "}
{totalCount}개 행
</div>
<div
className={`flex items-center space-x-2 ${
!displayText && "hidden"
}`}
>
<p className="text-sm font-medium">페이지당 행 개수</p>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[1,3, 5, 10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div
className={`flex w-[150px] items-center justify-center text-sm font-medium ${
!totalCountText && "hidden"
}`}
>
페이지 {page} / 총 {pageCount}
</div>
<div className="flex items-center space-x-1 ">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => setPage(1)}
disabled={page === 1}
aria-label="첫 페이지"
>
<span className="sr-only">첫 페이지로 이동</span>
<ChevronsLeft />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setPage(page - 1)}
disabled={page === 1}
aria-label="이전 페이지"
>
<span className="sr-only">이전 페이지로 이동</span>
<ChevronLeft />
</Button>
{/* 페이지 번호 버튼 */}
{getPaginationRange().map((item, index) =>
item === "..." ? (
<span key={index} className="px-2 text-muted-foreground">
...
</span>
) : (
<Button
key={index}
variant={currentPage === item ? "default" : "outline"}
className={`h-8 w-8 p-0 ${currentPage === item ? "bg-primary text-white" : ""}`}
onClick={() => setPage(item as number)}
aria-label={`페이지 ${item}`}
>
{item}
</Button>
)
)}
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setPage(page + 1)}
disabled={page === pageCount}
aria-label="다음 페이지"
>
<span className="sr-only">다음 페이지로 이동</span>
<ChevronRight />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => setPage(pageCount)}
disabled={page === pageCount}
aria-label="마지막 페이지"
>
<span className="sr-only">마지막 페이지로 이동</span>
<ChevronsRight />
</Button>
</div>
</div>
</div>
</div>
);
}
DataTablePagination 컴포넌트 설명
이 컴포넌트는 Next.js 15 + shadcn/ui 환경에서 @tanstack/react-table을 사용하는 테이블의 페이징 기능을 제공합니다. 다양한 옵션을 통해 위치 지정, 행 개수 선택, 총 개수 표시 등의 기능을 지원합니다.
DataTablePagination 컴포넌트 설명
1. 주요 Props 설명
interface DataTablePaginationProps<TData> {
position?: "center" | "left" | "right";
table: Table<TData>;
page: number;
pageSize: number;
totalCount: number;
setPage: (page: number) => void;
setPageSize: (pageSize: number) => void;
selectText?: boolean;
displayText?: boolean;
totalCountText?: boolean;
}
DataTablePagination 컴포넌트 설명
이 컴포넌트는 Next.js 15 + shadcn/ui 환경에서 @tanstack/react-table을 사용하는 테이블의 페이징 기능을 제공합니다. 다양한 옵션을 통해 위치 지정, 행 개수 선택, 총 개수 표시 등의 기능을 지원합니다.
개발 디렉토리 구조
src/app/api/payments/route.tsx src/app/payments/components/payments-data-table-column-header.tsx src/app/payments/components/payments-data-table-columns.tsx src/app/payments/components/payments-data-table.tsx src/app/payments/layout.tsx src/app/payments/loading.tsx src/app/payments/page.tsx src/components/data-table-pagination.tsx
- position: 페이징 UI의 정렬 방향 (기본값: center)
- table: @tanstack/react-table의 Table 객체
- page: 현재 페이지 번호
- pageSize: 한 페이지에 표시할 행 개수
- totalCount: 전체 데이터 개수
- setPage: 페이지 변경 함수
- setPageSize: 페이지 크기 변경 함수
- selectText, displayText, totalCountText: UI 요소 표시 여부
2. 주요 기능
(1) 페이지당 행 개수 설정
- Select 컴포넌트를 활용하여 사용자가 한 페이지에 표시할 행 개수를 조정 가능
- 기본 제공 옵션: [3, 5, 10, 20, 30, 40, 50]
- setPageSize(Number(value))를 호출하여 변경 반영
(2) 페이지 네비게이션 버튼
- 첫 페이지 (<<), 이전 페이지 (<), 다음 페이지 (>), 마지막 페이지 (>>) 버튼 제공
- 작은 화면에서는 <<, >> 버튼을 숨기거나 드롭다운으로 변경하여 반응형 디자인 적용
- setPage(page - 1), setPage(page + 1), setPage(1), setPage(pageCount) 호출하여 페이지 이동
- 현재 페이지에 해당하는 버튼은 bg-primary text-white 스타일 적용
(3) 페이지 번호 버튼 최적화
- 모든 페이지 번호를 렌더링하지 않고, 특정 범위의 페이지만 표시 (예: 1, 2, ..., 현재 페이지, ..., 마지막 페이지)
- 대량 데이터에서도 성능 저하 없이 효율적인 페이징 UI 제공
(4) 총 페이지 및 선택된 행 개수 표시
- 선택된 행 개수 및 전체 행 개수 (selectText 옵션)
- 현재 페이지 및 총 페이지 수 (totalCountText 옵션)
(5) 접근성(ARIA) 향상
- 페이지 네비게이션 버튼에 aria-label 추가하여 스크린 리더 사용자를 위한 접근성 개선
코드 개선 포인트
페이지 번호 버튼 최적화
- 현재 모든 페이지 번호 버튼을 렌더링하므로 많은 데이터가 있을 경우 성능 문제가 발생할 수 있음
- 특정 범위의 페이지만 렌더링하도록 개선 (1, 2, ..., 현재 페이지, ..., 마지막 페이지 형태로 출력)
반응형 디자인 개선
- 작은 화면에서는 <<, >> 버튼을 숨기거나 드롭다운으로 변경 가능
페이지 네비게이션 ARIA 속성 추가
- aria-label을 활용하여 접근성을 강화 가능
이 컴포넌트는 공통으로 활용할 수 있는 강력한 페이징 기능을 제공하며, 다양한 UI 옵션을 지원하는 점이 특징입니다.
✅ Next.js 15에서 React Query 적용하는 방법
다음 페이지 참조:
https://macaronics.net/index.php/m04/react/view/2365
Next.js 15에서는 App Router를 사용하며, 클라이언트 컴포넌트에서 React Query를 활용하여 데이터를 효율적으로 가져올 수 있습니다.
다음은 Next.js 15에서 React Query를 적용하는 방법을 단계별로 설명합니다.
1️⃣ React Query 및 Devtools 설치
pnpm install @tanstack/react-query @tanstack/react-query-devtools
또는
yarn add @tanstack/react-query @tanstack/react-query-devtools
2️⃣ React Query 클라이언트 설정
Next.js 15에서는 ReactQueryClientProvider를 별도의 파일로 만들어 전역적으로 React Query를 설정하는 것이 좋습니다.
providers/ReactQueryProvider.tsx 생성
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
interface ReactQueryProviderProps {
children: ReactNode;
}
export default function ReactQueryProvider({ children }: ReactQueryProviderProps) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}- useState를 사용하여 QueryClient를 한 번만 생성합니다.
- QueryClientProvider로 감싸서 React Query 기능을 사용할 수 있도록 설정합니다.
- ReactQueryDevtools를 추가하여 개발 도구를 활용할 수 있도록 합니다.
3️⃣ React Query Provider를 전체 레이아웃에 적용
Next.js 15에서는 layout.tsx를 활용하여 전역 상태를 관리할 수 있습니다.
app/layout.tsx 수정
import ReactQueryProvider from "@/providers/ReactQueryProvider";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "My Next.js 15 App",
description: "Using React Query with Next.js 15 App Router",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>
<ReactQueryProvider>
{children}
</ReactQueryProvider>
</body>
</html>
);
}- ReactQueryProvider를 layout.tsx에서 감싸주어 전역적으로 React Query를 사용할 수 있도록 설정합니다.
4️⃣ 클라이언트 컴포넌트에서 React Query 사용
Next.js 15에서는 기본적으로 서버 컴포넌트(server components)를 사용하지만, React Query는 클라이언트 컴포넌트에서만 동작합니다.
따라서 데이터를 가져오는 컴포넌트에 "use client"를 추가해야 합니다.
app/employees/page.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
const fetchEmployees = async () => {
const res = await fetch("/api/employees");
if (!res.ok) {
throw new Error("데이터를 불러오는 중 오류가 발생했습니다.");
}
return res.json();
};
export default function EmployeesPage() {
const { data, error, isLoading } = useQuery({
queryKey: ["employees"],
queryFn: fetchEmployees,
staleTime: 5000, // 5초 동안 캐시 유지
refetchOnWindowFocus: false, // 창 포커스 시 자동 새로고침 방지
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div className="text-red-500">데이터를 불러오는 중 오류가 발생했습니다.</div>;
return (
<div>
<h1 className="text-xl font-bold mb-4">직원 목록</h1>
<ul>
{data?.employees?.map((employee: any) => (
<li key={employee.id}>{employee.name}</li>
))}
</ul>
</div>
);
}- useQuery를 사용하여 데이터를 가져옵니다.
- queryKey: ["employees"]를 설정하여 데이터를 캐싱합니다.
- staleTime: 5000을 설정하여 5초 동안 캐시된 데이터를 유지합니다.
5️⃣ 데이터 갱신 (무효화)
React Query는 데이터를 자동으로 갱신할 수 있으며, 특정 이벤트에서 데이터를 다시 불러오도록 설정할 수 있습니다.
데이터 새로고침 버튼 추가
import { useQuery, useQueryClient } from "@tanstack/react-query";
export default function EmployeesPage() {
const queryClient = useQueryClient();
const { data, error, isLoading } = useQuery({
queryKey: ["employees"],
queryFn: fetchEmployees,
});
const handleRefresh = () => {
queryClient.invalidateQueries(["employees"]); // ✅ 데이터 갱신
};
return (
<div>
<button onClick={handleRefresh} className="bg-blue-500 text-white px-4 py-2 rounded mb-4">
데이터 새로고침
</button>
{/* 데이터 목록 */}
</div>
);
}
정리
✅ 전역적으로 React Query 설정 (ReactQueryProvider.tsx)
✅ 클라이언트 컴포넌트에서 useQuery 사용
✅ queryKey를 활용한 데이터 캐싱 & 페이지네이션
✅ 데이터 갱신을 위해 invalidateQueries 활용
✅ 개발 도구 ReactQueryDevtools 추가


















댓글 ( 0)
댓글 남기기