데이터 목록 ui 공통 컴포넌트, 훅으로 관리하기

2024. 5. 19. 22:29·프론트엔드/Next.js

관리자 웹, 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
'프론트엔드/Next.js' 카테고리의 다른 글
  • Next.js의 캐시 전략
  • Next.js 14 App router에 Auth.js v5 적용 후기
  • Next.js 14 기초 활용법
정현우12
정현우12
  • 정현우12
    정현우의 개발 블로그
    정현우12
  • 전체
    오늘
    어제
    • 분류 전체보기 (79)
      • 프론트엔드 (56)
        • JavaScript, TypeScript (12)
        • 스타일링 (1)
        • React (13)
        • Next.js (4)
        • 개발 환경 (9)
        • 테스트 (3)
        • 성능 최적화 (11)
        • 함수형 프로그래밍 (2)
        • 구조 (1)
      • 프로젝트 회고 (23)
        • 이미지편집기 개발 (7)
        • 엑셀 다운로드, 업로드 공통 모듈 개발 (4)
        • 사용자 매뉴얼 사이트 개발 (3)
        • 통계자동화 솔루션 개발 (1)
        • 엑셀 편집기 개발 (5)
        • API 플랫폼 (1)
        • 콜센터 솔루션 OB 캠페인 (1)
        • AI 스튜디오 (1)
      • 백엔드 (0)
  • 블로그 메뉴

    • 홈
    • 포트폴리오
    • 태그
  • 인기 글

  • 태그

    엑셀
    렌더링 성능 최적화
    사용자 매뉴얼 사이트
    JavaScript
    frontend
    로딩 성능 최적화
    memoization
    webpack
    회고
    React
    이미지 편집기
    커스텀 훅
    Github Actions
    Next.js
    웹 성능 최적화
    TypeScript
    라이브러리 선정
    엑셀 에디터
    React-boilerplate
    useReducer
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
정현우12
데이터 목록 ui 공통 컴포넌트, 훅으로 관리하기
상단으로

티스토리툴바