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)
댓글 남기기