React

 

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/uitanstack/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. 주요 기능

  1. 정렬 기능

    • column.getCanSort()이 false이면 정렬이 불가능한 컬럼이므로 단순히 제목만 표시합니다.
    • 그렇지 않으면 드롭다운 메뉴를 제공하여 정렬 옵션을 제공합니다.
    • 정렬 상태에 따라 오름차순(▲), 내림차순(▼), 정렬되지 않음(⇅) 아이콘을 동적으로 변경합니다.
    • column.toggleSorting(false): 오름차순 정렬 수행
    • column.toggleSorting(true): 내림차순 정렬 수행
  2. 컬럼 숨기기 기능

    • column.toggleVisibility(false)를 호출하여 컬럼을 숨길 수 있습니다.

 

3. 사용된 UI 요소

  • shadcn/ui 라이브러리 컴포넌트
    • Button: 정렬 버튼을 렌더링
    • DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger: 정렬 및 숨김 옵션을 제공하는 드롭다운 메뉴 구성
  • 아이콘 (lucide-react 라이브러리)
    • ArrowDown: 내림차순 정렬 아이콘
    • ArrowUp: 오름차순 정렬 아이콘
    • ChevronsUpDown: 정렬되지 않은 상태 아이콘
    • EyeOff: 컬럼 숨기기 아이콘

 

4. 주요 동작 흐름

  1. column.getCanSort()를 확인하여 정렬 가능 여부를 판단합니다.
  2. 정렬 가능한 경우, 드롭다운 버튼을 생성하여 정렬 상태를 표시합니다.
  3. 드롭다운 메뉴에는 오름차순, 내림차순, 컬럼 숨기기 옵션이 포함됩니다.
  4. 사용자가 정렬을 변경하면 column.toggleSorting()을 호출하여 새로운 정렬 상태를 적용합니다.
  5. 사용자가 컬럼을 숨기려 하면 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. 페이지 번호 버튼 최적화

    • 현재 모든 페이지 번호 버튼을 렌더링하므로 많은 데이터가 있을 경우 성능 문제가 발생할 수 있음
    • 특정 범위의 페이지만 렌더링하도록 개선 (1, 2, ..., 현재 페이지, ..., 마지막 페이지 형태로 출력)
  2. 반응형 디자인 개선

    • 작은 화면에서는 <<, >> 버튼을 숨기거나 드롭다운으로 변경 가능
  3. 페이지 네비게이션 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 추가

     

     

     

     

     

     

     

     

     

    about author

    PHRASE

    Level 60  라이트

    천하를 있는 그대로 둔다. 이것이 최상의 정책이다. -장자

    댓글 ( 0)

    댓글 남기기

    작성