페이지네이션 (Pagination) 이란 ?


https://blog.kakaocdn.net/dn/bohzDQ/btrYWab7RqY/Bia8fHCI4p8Rzs0IX0zqsK/img.png

API 를 통해서 많은 양의 데이터를 가져와 화면에 렌더링해야 하는 경우 자칫하면 성능 문제를 야기할 수 있습니다.

이 때 성능을 최적화하기 위한 다양한 방법 중 전통적인 방법으로 페이지네이션이 있습니다.

페이지네이션은 데이터의 갯수를 미리 알고 있고 데이터의 추가나 삭제가 자주 일어나지 않을 때 유용합니다.

더 나은 경험을 위한 페이지네이션


React Project 생성

npx create-react-app ./ --template typescript

패키지 설치

npm install @emotion/styed @emotion/react react-icons

usePagination.ts (custom hook)

import React from 'react'

interface Props {
  count: number // 페이지 전체 개수
  page: number // 현재 페이지
  onPageChange: (page: number) => void
  disabled?: boolean
  siblingCount?: number //현재페이지 전 후에 표시되는 숫자
  boundaryCount?: number //시작과 끝에서 항상 표시되는 숫자
}

export default function usePagination({
  count,
  page,
  onPageChange,
  disabled,
  siblingCount = 1,
  boundaryCount = 2,
}: Props) {
  // 시작과 끝을 지정했을 시 그 사이에 숫자를 배열로 반환
  // ex range(1,5) = [1,2,3,4,5] 반환
  const range = (start: number, end: number) => {
    const length = end - start + 1
    return Array.from({ length }).map((_, index) => index + start)
  }

  const startPage = 1 // 시작페이지
  const endPage = count // 전체 페이지 = 끝 페이지

  // 시작 페이지
  // 시작 페이지(1) ~ (바운더리카운트, 전체페이지 중 작은 수) 의 배열
  const startPages = range(startPage, Math.min(boundaryCount, count))

  // 끝 페이지
  // (전체페이지 - 바운더리카운트 + 1), (바운더리카운트 + 1) 중 높은 숫자 ~ 전체페이지
  const endPages = range(
    Math.max(count - boundaryCount + 1, boundaryCount + 1),
    count
  )

  // 현재페이지 앞
  // (현재페이지 + 1 - 현재 앞뒤 설정개수,) (전체페이지 - 맨앞맨끝 설정개수 - 현재 앞뒤 설정개수 * 2 - 1 )중 작은 수
  const siblingStart = Math.max(
    Math.min(
      page + 1 - siblingCount,
      count - boundaryCount - siblingCount * 2 - 1
    ),
    boundaryCount + 2
  )

  // 현제페이지 뒤
  const siblingEnd = Math.min(
    Math.max(page + 1 + siblingCount, boundaryCount + siblingCount * 2 + 2),
    endPages.length > 0 ? endPages[0] - 2 : endPage - 1
  )

  const itemList = [
    'prev',
    ...startPages,
    ...(siblingStart > boundaryCount + 2
      ? ['start-ellipsis']
      : boundaryCount + 1 < count - boundaryCount
      ? [boundaryCount + 1]
      : []),
    ...range(siblingStart, siblingEnd),
    ...(siblingEnd < count - boundaryCount - 1
      ? ['end-ellipsis']
      : count - boundaryCount > boundaryCount
      ? [count - boundaryCount]
      : []),
    ...endPages,
    'next',
  ]

  const items = itemList.map((item, index) =>
    typeof item === 'number'
      ? {
          key: index,
          onClick: () => onPageChange(item - 1),
          disabled,
          selected: item - 1 === page,
          item,
        }
      : {
          key: index,
          onClick: () => onPageChange(item === 'next' ? page + 1 : page - 1),
          disabled:
            disabled ||
            item.indexOf('ellipsis') > -1 ||
            (item === 'next' ? page >= count - 1 : page < 1),
          selected: false,
          item,
        }
  )
  return { items }
}

Pagination.tsx

import React from 'react'
import styled from '@emotion/styled/macro'
import { GrFormPrevious, GrFormNext } from 'react-icons/gr'
import { AiOutlineEllipsis } from 'react-icons/ai'
import usePagination from '../hooks/usePagination'

interface Props {
  count: number
  page: number
  onPageChange: (page: number) => void
  disabled?: boolean
  siblingCount?: number //현재페이지 전 후에 표시되는 숫자
  boundaryCount?: number //시작과 끝에서 항상 표시되는 숫자
}

const Navigation = styled.nav``

const Button = styled.button<{ selected?: boolean }>`
  color: ${({ selected }) => (selected ? '#fff' : '#000')};
  border: 0;
  margin: 0;
  padding: 8px 12px;
  font-size: 16px;
  font-weight: normal;
  background-color: ${({ selected }) => (selected ? '#3d6afe' : '#fff')};
  cursor: pointer;
  border-radius: 100%;
  width: 48px;
  height: 48px;
  &:hover {
    background-color:#ccc;
    color:#fff;
  }
  &:active {
    opacity: 0.8;
  }
`

const PaginationItem = styled.li``

const PaginationItemList = styled.ul`
  margin: 0;
  padding: 0;
  display: flex;
  list-style: none;
  ${PaginationItem} + ${PaginationItem} {
    margin-left: 8px;
  }
`

export default function Pagination({
  count,
  page,
  onPageChange,
  disabled,
  siblingCount,
  boundaryCount,
}: Props) {
  const getLabel = (item: number | string) => {
    if (typeof item === 'number') return item
    else if (item.indexOf('ellipsis') > -1) return <AiOutlineEllipsis />
    else if (item.indexOf('prev') > -1) return <GrFormPrevious />
    else if (item.indexOf('next') > -1) return <GrFormNext />
    else return item
  }

  const { items } = usePagination({
    count,
    page,
    onPageChange,
    disabled,
    siblingCount,
    boundaryCount,
  })

  return (
    <Navigation>
      <PaginationItemList>
        {items.map(({ key, disabled, selected, onClick, item }) => (
          <PaginationItem key={key}>
            <Button disabled={disabled} selected={selected} onClick={onClick}>
              {getLabel(item)}
            </Button>
          </PaginationItem>
        ))}
      </PaginationItemList>
    </Navigation>
  )
}

App.tsx