관리자 웹, CMS를 개발할 때 데이터 목록을 많은 페이지에서 보여준다. (예시: shadcn/ui table)

Next.js 에서 이 데이터 목록 ui를 공통 컴포넌트, 훅으로 따로 빼서 중복을 줄이고 개발 시간을 빠르게 해봤다.
구성
데이터 목록 ui는 크게 3가지 영역으로 나눌 수 있다.

- 필터
- 검색어, 상태, 기간, 목록 건수 등 데이터 필터링 액션
- 테이블
- 데이터를 목록 형태로 보여줌
- 페이지네이션
- 데이터 목록 페이지 이동 액션
필터, 페이지네이션 영역에서 사용자 액션이 발생 시 테이블에서 보여주는 데이터가 변경되어야 한다.
react만 사용한다면 context로 3개 영역을 묶고 데이터를 공유하는 식으로 구현할 수 있다.
Next.js를 사용할 때는 다른 방식을 사용할 수 있다. 사용자 액션 발생시에 url search params를 변경시키고, 테이블은 useSearchparams(클라이언트), searchParams props(서버)를 활용해서 search params 변경을 감지하고, 다시 data를 fetch해온다.
구현
필터
// table-search.tsx
function TableSearch({ placeholder }: { placeholder: string }) {
const { replace } = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const inputRef = useRef<HTMLInputElement>(null)
const onSearch = () => {
const query = inputRef?.current?.value
const params = new URLSearchParams(searchParams)
params.set("page", "0")
if (query) {
params.set("query", query)
} else {
params.delete("query")
}
replace(`${pathname}?${params.toString()}`)
}
return (
<div className="flex">
<Input
placeholder={placeholder}
className="mx-1"
ref={inputRef}
onKeyDown={(e) => {
if (e.key === "Enter") {
onSearch()
}
}}
/>
<Button onClick={onSearch}>
<SearchIcon className="size-4" />
</Button>
</div>
)
}
export default TableSearch
필터의 구현은 검색어 입력후 검색, 상태 탭 클릭 등 사용자 액션 발생시에 url의 search params를 변경해준다.
기간, 상태 필터도 위와 동일한 방식으로 구현할 수 있다.
테이블
테이블은 shadcn/ui의 테이블을 활용해서 구현했다. table 태그 사용법과 거의 동일하다.
테이블은 각 데이터마다 형태가 많이 다르다. action 버튼들이 들어가거나 컬럼별 필터가 들어가는 식이기 때문에 공통으로 묶어서 items를 props로 받기보다는 ui부분만 컴포넌트화해서 사용하는 편이 좋은 것 같다.
<Table>
<TableHeader>
{/* 생략 */}
</TableHeader>
<TableBody>
{histories && histories.length === 0 && (
<TableRow>
<TableCell className="text-muted-foreground">
검색 결과가 없습니다.
</TableCell>
</TableRow>
)}
{histories &&
histories.map((history) => (
<TableRow
key={history.id}
className={cn("cursor-pointer", {
"bg-muted/50": history.id === Number(selectedId),
})}
onClick={() => onClickItem(history.id)}
>
<TableCell className="w-1/2">{history.logId}</TableCell>
<TableCell className="hidden md:table-cell md:w-1/4 xl:w-1/5">
{history.model}
</TableCell>
<TableCell className="hidden md:table-cell md:w-1/4 xl:w-1/5">
{history.type}
</TableCell>
<TableCell className="w-1/2">{history.content}</TableCell>
<TableCell className="hidden xl:table-cell xl:w-1/5">
{history.createdAt}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
페이지네이션
페이지네이션은 필터와 비슷하게 사용자 액션 발생시 데이터 목록의 데이터를 변경한다.
필터와 다른 점은 서버에서 필터 상태에 맞는 데이터 리턴 시에 전체 개수를 응답에 담아서 주면, 해당 응답을 기준으로 페이지를 계산해야 한다는 점이다.
즉, 서버에 의존성이 있다.
페이지를 계산하는 부분은 따로 커스텀 훅으로 빼서 관리했다. 데이터 전체 개수, 목록 노출 건수를 props로 받아서 페이지, 이전, 이후, 맨앞, 맨끝 이동 함수 등을 리턴해준다.
// use-table-pager.ts
export function useTablePager({
dataTotalCount,
size,
}: {
dataTotalCount: number
size: number
}) {
const { replace } = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const page =
searchParams.get("page") !== null ? Number(searchParams.get("page")) : 0
const setPage = useCallback(
(page: number) => {
const params = new URLSearchParams(searchParams)
params.set("page", String(page))
replace(`${pathname}?${params.toString()}`)
},
[pathname, replace, searchParams]
)
const totalPageCnt =
dataTotalCount === 0 ? 1 : Math.ceil(dataTotalCount / size)
const onNumber = useCallback(
(number: number) => {
setPage(number)
},
[setPage]
)
const onPrevious = useCallback(() => {
setPage(page - 1)
}, [page, setPage])
const onNext = useCallback(() => {
setPage(page + 1)
}, [page, setPage])
const onFirst = useCallback(() => {
setPage(0)
}, [setPage])
const onLast = useCallback(() => {
setPage(totalPageCnt - 1)
}, [setPage, totalPageCnt])
const canNext = page !== totalPageCnt - 1
const canPrevious = page !== 0
const canFirst = canPrevious
const canLast = canNext
return {
page,
setPage,
onNumber,
onPrevious,
onNext,
onFirst,
onLast,
canNext,
canPrevious,
canFirst,
canLast,
totalPageCnt,
}
}
첫번째 page를 0으로 한 이유는 백엔드가 일단 그렇게 되어 있어서 혼동을 주지 않기 위해서 0으로 정했다. 보여줄 때는 +1해서 보여준다.
페이지네이션 컴포넌트는 현재 페이지 기준으로 근처 3개 페이지 숫자와, << , < , > , >> 버튼을 노출한다. 각 버튼의 disabled여부는 위 useTablePager hook에서 계산한 걸 받아온다.
ui는 shadcn/ui의 pagination 컴포넌트를 활용했다.
// table-pager.tsx
function TablePager({
page,
setPage,
onNumber,
onPrevious,
onNext,
onFirst,
onLast,
canNext,
canPrevious,
canFirst,
canLast,
totalPageCnt,
}: ReturnType<typeof useTablePager>) {
const nearPages = calcNearPages(page, totalPageCnt)
return (
<Pagination className="mt-2 h-[40px]">
<PaginationContent>
<PaginationItem>
<PaginationFirst onClick={onFirst} disabled={!canFirst} />
</PaginationItem>
<PaginationItem>
<PaginationPrevious onClick={onPrevious} disabled={!canPrevious} />
</PaginationItem>
{nearPages[0] >= 1 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
{nearPages.map((_page) => (
<PaginationItem key={_page}>
<PaginationLink
isActive={page === _page}
// disabled={page === _page}
onClick={() => onNumber(_page)}
>
{_page + 1}
</PaginationLink>
</PaginationItem>
))}
{nearPages[nearPages.length - 1] < totalPageCnt - 1 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationNext onClick={onNext} disabled={!canNext} />
</PaginationItem>
<PaginationItem>
<PaginationLast onClick={onLast} disabled={!canLast} />
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
useTable hook
테이블 관련 컴포넌트는 보통 한 부모 컴포넌트에 묶여서 사용된다. 그렇기 때문에 3개 컴포넌트 구성 로직을 따로 커스텀 훅으로 묶었다.
클라이언트에서 데이터 fetch하는 방식을 채택했기 때문에 (사용자, 사용자 프로젝트 별로 데이터 다르기 때문) 데이터 fetch 함수(react query 사용)와 필터를 props로 받으면 Table 컴포넌트를 사용할 수 있는 tablePagerProps, tableSizerProps, onClickItem, items 등을 리턴한다.
// use-table.ts
type TableFilters = "query" | "status" | "start" | "end" | "page" | "size"
function useTable<Items, RemoteQueryParams>({
useRemoteQuery,
useRemoteQueryParams,
}: {
useRemoteQuery: (
params: RemoteQueryParams
) =>
| UseQueryResult<ResultEntity<{ totalCount: number; elements: Items }>>
| UseSuspenseQueryResult<
ResultEntity<{ totalCount: number; elements: Items }>
>
useRemoteQueryParams: Array<keyof RemoteQueryParams>
}) {
// url 훅
const searchParams = useSearchParams()
const { replace } = useRouter()
const pathname = usePathname()
// 필터
const query = searchParams.get("query") ?? ""
const status = searchParams.get("status") ?? "ALL"
const start = searchParams.get("start") ?? getToday()
const end = searchParams.get("end") ?? getToday()
// 목록 항목 선택
const selectedId = searchParams.has("selectedId")
? Number(searchParams.get("selectedId"))
: null
const onClickItem = (id: number) => {
const params = new URLSearchParams(searchParams)
params.set("selectedId", String(id))
replace(`${pathname}?${params.toString()}`)
}
// 목록 항목 개수
const { size, setSize } = useTableSizer()
// 페이지네이션
const [dataTotalCount, setDataTotalCount] = useState<number>(0)
const { page, setPage, ...restPagerProps } = useTablePager({
dataTotalCount,
size,
})
// fetch params 빌드
// TODO: 타입 단언 없애기..
const filter = {
query,
status,
start,
end,
page,
size,
} as { [key in keyof RemoteQueryParams]: string }
const remoteQueryParams = useRemoteQueryParams.reduce(
(acc, key) => ({ [key]: filter[key], ...acc }),
{}
) as RemoteQueryParams
// fetch
const queryResult = useRemoteQuery(remoteQueryParams)
const items = queryResult?.data?.data.elements ?? []
const totalCount = queryResult?.data?.data.totalCount ?? 0
useEffect(() => {
setDataTotalCount(totalCount)
}, [totalCount])
return {
tableSizerProps: {
size,
setSize,
totalCount,
},
tablePagerProps: {
page,
setPage,
...restPagerProps,
},
items,
onClickItem,
selectedId,
}
}
export default useTable
searchParams에서 각 필터 상태를 읽고, 변경시에 remote에 fetch해서 데이터와 데이터 전체 개수를 받아온다. 데이터 전체개수로 pagerProps와 sizerProps를 만들어서 리턴 해준다. 그 외에 items와 선택로직도 포함해서 리턴한다.
사용
위 3개 컴포넌트와 useTable 훅을 사용하는 법은 다음과 같다.
// history-table.tsx
function HistoryTable() {
const {
tableSizerProps,
tablePagerProps,
items: histories,
onClickItem,
selectedId,
} = useTable<Histories, GetHistoriesParams>({
useRemoteQuery: useHistoriesQuery,
useRemoteQueryParams: ["query", "start", "end", "size", "page"],
})
return (
<div className="size-full">
<TableSizer {...tableSizerProps} />
<ScrollArea className="h-[calc(100%-40px-40px)] w-full">
<Table>
{/* 구현 > 테이블 내용과 동일 */}
</Table>
</ScrollArea>
<TablePager {...tablePagerProps} />
</div>
)
}
export default HistoryTable
매우 간단하게 사용할 수 있다. 현재 프로젝트 3개 페이지에서 위와 같은 형태로 목록을 구현해놨다. fetch 함수와 params만 데이터에 맞게 변경해주면 바로바로 목록 ui를 만들 수 있다.
정리
- 데이터 목록 ui를 공통 컴포넌트, 훅으로 관리하면 비슷한 ui 제작시에 매우 빠르게 개발할 수 있다.
- 잘 만들어 놓은 공통 컴포넌트, 훅은 개발 기간을 많이 단축할 수 있다.
- 사용자, 사용자 관리 프로젝트 별로 다르기 때문에 데이터 fetch를 클라이언트에서 했다. 이커머스와 같이 많은 사용자가 동일한 데이터 목록을 보는 경우라면 서버 컴포넌트 써서 서버에서 fetch할 수 있다.
'프론트엔드 > Next.js' 카테고리의 다른 글
Next.js의 캐시 전략 (1) | 2025.02.09 |
---|---|
Next.js 14 App router에 Auth.js v5 적용 후기 (0) | 2024.08.25 |
Next.js 14 기초 활용법 (1) | 2024.02.24 |