페이지네이션(frontend only)

Donggu(oo)·2023년 1월 22일
0

React 기능 구현

목록 보기
8/14
post-thumbnail

1. 페이지네이션 알고리즘


1) 전체 페이지 개수 구하기

  • 첫 번째는 게시물을 여러 페이지에 나누어서 표시하려면 총 몇 개의 페이지가 필요한지 알아야 한다. 이때, 전체 게시물 수를 페이지 당 표시할 게시물 수로 나눈 뒤 올림하면 총 몇 개의 페이지가 필요한지 구할 수 있다.

전체 페이지 개수 구하기

Math.ceil(전체 게시물(데이터)/ 페이지 당 표시할 게시물(데이터))
  • 예를 들어, 총 37개의 게시물이 있고, 페이지 당 10개의 게시물을 표시하려고 한다면, 37 / 10 = 3.7, 여기서 올림하면 4개의 페이지가 필요하게 된다. (1~3 페이지에는 10개씩 게시물이 표시되고, 4페이지에는 7개의 게시물이 표시되는 형식)
const numPages = Math.ceil(todoData.length(37) / limit(10));

2) 해당 페이지의 첫 게시물 위치 구하기

  • 두번째로 알아야할 부분은 현재 페이지 번호를 기준으로 표시해줘야할 게시물들의 범위, 즉, 해당 페이지의 첫 게시물의 위치(index)를 알아야한다.

  • 페이지 번호에서 1을 뺀 후에 페이지 당 표시할 게시물의 수를 곱하면 첫 게시물의 위치를 계산할 수 있다. (마지막 게시물의 위치는 첫 게시물의 위치에서 단순히 페이지 당 표시할 게시물의 수만 더해주면 된다.)

첫 게시물 위치 구하기

(페이지 번호 - 1) * 페이지 당 표시할 게시물의 수
  • 예를 들어, 위와 동일한 총 37개의 게시물이 있고, 페이지 당 10개의 게시물을 표시되야 한다면 아래와 같이 구할 수 있다. (index는 0부터 시작하기 때문에 1을 빼준다.)

    • 1번째 페이지의 첫 게시물의 위치(index) : (1 - 1) * 10 = 0
    • 2번째 페이지의 첫 게시물의 위치(index) : (2 - 1) * 10 = 10
    • 3번째 페이지의 첫 게시물의 위치(index) : (3 - 1) * 10 = 20
    • 4번째 페이지의 첫 게시물의 위치(index) : (4 - 1) * 10 = 30
const offset = (page - 1) * limit(10)

2. 현재 페이지에 해당하는 게시물만 보여주기


  • 전체 게시물 대신에 현재 페이지에 해당하는 게시물만 보여줄 수 있도록 컴포넌트를 변경한다.

  • 먼저 페이지 당 표시할 게시물 수(limit), 현재 페이지 번호(page)를 상태로 추가하고, 첫 게시물의 위치(offset)를 계산한다.

// limit 상태 변수는 현재 페이지에 표시할 데이터의 개수를 나타낸다.
const [limit, setLimit] = useState(10);  // 기본값: 10개씩 노출
// page 상태 변수는 현재 몇 번째 페이지인지를 나타낸다.
const [page, setPage] = useState(1);  // 기본값: 1페이지부터 노출
// `offset`은 각 페이지에서의 첫 번째 데이터의 위치(index)를 나타낸다.
const offset = (page - 1) * limit;
  • 그리고 페이지 당 표시할 게시물 수를 선택을 드롭다운으로 만들 경우 <select> 요소를 사용한다.
// 데이터를 10개, 20개, 30개 씩 보여줌
<select type="number" value={limit} onChange={(e) => setLimit(Number(e.target.value))}>
    <option value="10">10개씩</option>
    <option value="20">20개씩</option>
    <option value="30">30개씩</option>
</select>
  • 페이지 당 표시할 게시물 수 선택을 드롭다운이 아닌 버튼으로 만들 경우 map 으로 버튼을 만든다.
// 데이터를 10개, 20개, 30개 씩 보여줌
const limitList = [10, 20, 30];

{limitList.map((el, idx) => {
  return (
    <button key={idx} value={el} onClick={(e) => setLimit(Number(e.target.value))}>
      {el}
    </button>
  );
})}
  • 마지막으로 기존에는 전체 데이터를 불러와서 한 페이지에 다 보여주었지만, slice()를 사용하여 첫 게시물부터 선택한 게시물의 수만 보여주도록 변경한다.

ex

  • 첫 번째 페이지의 경우 offset이 0이고(page === 1) limit이 10이라면 데이터의 0번째 index부터 9번째 index까지만 데이터를 불러온다.
  • 두 번째 페이지의 경우 offset이 10이고(page === 2) limit이 10이라면 데이터의 10번째 index부터 19번째 index까지만 데이터를 불러온다.
// 기존
{todoData.map((value) =>
    <TodoList
        list={value}
        key={value.id}
        getTodoData={getTodoData}
    />
)}

// 변경
{todoData.slice(offset, offset + limit).map((value) =>
    <TodoList
        list={value}
        key={value.id}
		getTodoData={getTodoData}
    />
)}

3. 페이지네이션 컴포넌트 구현


  • 페이지를 이동할 수 있도록 해주는 <Pagination/> 컴포넌트는 이전 페이지나 다음 페이지 또는 특정 페이지로 바로 이동할 수 있는 많은 버튼으로 구성되어야 한다.

  • <Pagination/> 컴포넌트는 재활용 가능하도록 별도의 컴포넌트로 만들 경우 데이터가 들어가는 컴포넌트로부터 총 게시물 수(total)페이지 당 게시물 수(limit) 그리고 현재 페이지 번호(page, setPage)props로 받아와야 한다.

  • 필요한 페이지의 개수(numPages)를 계산한 후 루프를 돌면서 이 페이지의 개수만큼 페이지 번호 버튼을 출력한다.

// allPageLength === todoData.length
const numAllPages = Math.ceil(allPageLength / limit);
  • 페이지 번호 버튼에 클릭 이벤트가 발생하면 props로 넘어온 setPage() 함수를 호출하여 부모 컴포넌트의 page 상태가 변경되도록 한다. 그러면 부모 컴포넌트는 새로운 페이지 번호에 해당하는 게시물 범위를 계산하여 다시 화면을 렌더링하게 된다.

  • 아래의 예시에서 new Array(numAllPages).fill()의 의미는 먼저 길이가 numAllPages인 빈 배열을 만들고, 각 원소를 undefined로 채워준다. .fill()을 사용하여 undefined로 채워주는 이유는 empty인 경우 map을 사용할 수 없기 때문이다.

  • 그리고 {index + 1}은 생성된 빈 배열의 undefiend 요소의 개수만큼 버튼을 만든다.

{new Array(numAllPages).fill().map((_, index) => (
    <button
        className={page === index + 1 ? 'pageTab pageFocused' : 'pageTab'}
        key={idx + 1}
        onClick={() => setPage(index + 1)}>  {/* 클릭한 페이지로 바로 이동하는 버튼 이벤트 핸들러 */}
        {index + 1}
    </button>
))}

.fill.map()과 Array.from()

  • 아래의 2개의 코드 모두 페이지네이션 기능이 정상적으로 동작한다.
  1. .fill.map()
// fill()이 없으면 배열 생성시 undefined가 아닌 empty로 담김.
// 즉, empty면 map을 돌 수 없기 때문에 fill()이 필요함
{new Array(numAllPages).fill().map((_, index) => (
    <button key={index + 1} onClick={() => setPage(index + 1)}>
        {index + 1}
    </button>
))}
  1. Array.from()
{Array.from({ length: numAllPages }, (_, index) => (
    <button key={index + 1} onClick={() => setPage(index + 1)}>
        {index + 1}
    </button>
))}

3-1. 전체 코드


  • Main.js
import TodoList from "./TodoList";
import Pagenation from "./Pagenation";
import axios from "axios";
import { useState, useEffect } from "react";

function Main() {
    const [todoData, setTodoData] = useState([]);  // 전체 데이터가 담겨 있는 변수

    // Get
    const getTodoData = async () => {
        const res = await axios.get('http://localhost:3001/todos');
        setTodoData(res.data);
    };
    useEffect(() => {
        getTodoData();
    }, []);

    const [limit, setLimit] = useState(10);  // 페이지 당 표시할 데이터 수 (기본값: 10개씩 노출)
    const [page, setPage] = useState(
      () => JSON.parse(window.localStorage.getItem("currentPage")!) || 1
  	); // 현재 페이지 번호 (기본값: 1페이지부터 노출)
    const offset = (page - 1) * limit;  // 각 페이지에서 첫 데이터의 위치(index) 계산
  
    useEffect(() => {
      window.localStorage.setItem("currentPage", JSON.stringify(page));
    }, [page]);

    return (
        <>
            <select className="dropDown" type="number" value={limit} onChange={(e) => setLimit(Number(e.target.value))}>
                <option value="5">5개씩</option>
                <option value="10">10개씩</option>
                <option value="20">20개씩</option>
                <option value="30">30개씩</option>
            </select>
            <ul>
                {todoData.slice(offset, offset + limit).map((value) =>
                    <TodoList
                        list={value}
                        key={value.id}
                        getTodoData={getTodoData}
                    />)}
            </ul>
            <Pagenation
                allPageLength={todoData.length}
                limit={limit}
                page={page}
                setPage={setPage}
            />
        </>
    );
}

export default Main;

로컬스토리지를 이용하여 현재 페이지 번호 저장

  • 위 코드에서 현재 페이지의 번호를 const [page, setPage] = useState(1); 이렇게 그냥 1로 지정할 경우 2페이지의 게시글에 들어간 후 뒤로가기를 하면 다시 1페이지로 돌아가버리는 것을 방지하기 위해 로컬스토리지로 현재 페이지 번호를 저장 해둔다.
  const [page, setPage] = useState(
    () => JSON.parse(window.localStorage.getItem("currentPage")!) || 1
  );

  useEffect(() => {
    window.localStorage.setItem("currentPage", JSON.stringify(page));
  }, [page]);
  • Pagenation.js
function Pagenation({ allPageLength, limit, page, setPage }) {
    // 필요한 페이지 개수 === 총 데이터 수(allPageLength === todoData.length) / 페이지 당 표시할 데이터 수(limit === 10)
    const numAllPages = Math.ceil(allPageLength / limit);

    // 현재 페이지의 이전 페이지로 이동하는 버튼 이벤트 핸들러
    const prevPageHandler = () => {
        setPage(page - 1);
    };

    // 현재 페이지의 다음 페이지 이동하는 버튼 이벤트 핸들러
    const nextPageHandler = () => {
        setPage(page + 1);
    };

    return (
        <div>
            {/* 왼쪽 버튼 클릭시 현재 페이지에서 1 페이지 이전 페이지로 이동하고, 현재 페이지가 1페이지가 되면 왼쪽 버튼은 비활성화 된다.*/}
            <button className="leftHandle" onClick={prevPageHandler} disabled={page === 1}>
                &lt;
            </button>
            {new Array(numAllPages).fill().map((_, index) => (
                <button className={page === index + 1 ? 'pageTab pageFocused' : 'pageTab'} key={index + 1} onClick={() => setPage(index + 1)}>  {/* 클릭한 페이지로 바로 이동하는 버튼 이벤트 핸들러 */}
                    {index + 1}
                </button>
            ))}
            {/* 오른쪽 버튼 클릭시 현재 페이지에서 1 페이지 이후 페이지로 이동하고, 현재 페이지가 마지막 페이지가 되면 오른쪽 버튼은 비활성화 된다.*/}
            <button className="rightHandle" onClick={nextPageHandler} disabled={page === numAllPages}>
                &gt;
            </button>
        </div>
    );
}

export default Pagenation;

4. 페이징 처리 추가 구현


  • 위의 방법대로 하면 페이지네이션 기능은 정상적으로 동작하지만, 만약 페이지가 100개라면 버튼도 100개가 생성되어 매우 복잡해질 것이다.

    • 10개씩 페이지를 보여주고 싶지만 마지막 페이지까지 노출되는 상태
  • 그러므로 한 페이지에서는 10개의 페이지 버튼만 보여주고 10페이지에서 다음 페이지 버튼을 누를 시 11부터 20까지의 버튼을 다시 10개씩 보여주는 페이징 기능이 추가로 구현되어야 한다.

    • 10개씩 페이지를 끊어서 보여주는 상태

4-1. 전체 코드

  • Main.js는 위와 동일

  • Pagenation.js

import styled from 'styled-components';
import { useState } from 'react';

const PageNum = styled.div`
    margin-top: 20px;
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 5px;
    user-select: none;

    > .pageTab, .leftHandle, .rightHandle {
        width: 20px;
        height: 20px;
        background-color: transparent;
        border: none;
        color: ${(props) => props.theme.text};
    }

    > .pageFocused {
        border-radius: 3px;
        background-color: #f9f9f9;
        border: 1px solid black;
        font-weight: 600;
        color: black
        
    }

    > button:hover {
        text-decoration: underline;
    }
`;

function Pagenation({ allPageLength, limit, page, setPage }) {
  const [blockNum, setBlockNum] = useState(0);  // 페이지 당 표시할 페이지네이션 수
  const pageLimit = 10;  // 페이지 당 표시할 페이지네이션 수 (기본값 : 10개의 페이지네이션 노출)
  const blockArea = blockNum * pageLimit;  // 각 페이지에서 첫 페이지네이션의 위치 계산

  // 필요한 페이지 개수 === 총 데이터 수(allPageLength === todoData.length) / 페이지 당 표시할 데이터 수(limit === 10)
  const numAllPages = Math.ceil(allPageLength / limit);

  // 새로운 배열 생성 함수
  const createArr = (n) => {
    const iArr = new Array(n);
    for (let i = 0; i < n; i++) {
      iArr[i] = i + 1;
    }
    return iArr;
  };
  const allArr = createArr(numAllPages);  // nArr 함수에 전체 페이지의 개수를 배열로 담음

  // 현재 페이지의 이전 페이지로 이동하는 버튼 이벤트 핸들러
  const prevPageHandler = () => {
    if (page <= 1) {
      return;
    } else if (page - 1 <= pageLimit * blockNum) {
      setBlockNum((n) => n - 1);
    }
    setPage((n) => n - 1);
  }

  // 현재 페이지의 다음 페이지 이동하는 버튼 이벤트 핸들러
  const nextPageHandler = () => {
    if (page >= numAllPages) {
      return;
    } else if (pageLimit * (blockNum + 1) < page + 1) {
      setBlockNum((n) => n + 1);
    }
    setPage((n) => n + 1);
  };

  return (
    <>
      <PageNum>
        {/* 왼쪽 버튼 클릭시 현재 페이지에서 1 페이지 이전 페이지로 이동하고, 현재 페이지가 1페이지가 되면 왼쪽 버튼은 비활성화 된다.*/}
        <button className="leftHandle" onClick={prevPageHandler} disabled={page === 1}>
          &lt;
        </button>
        {allArr.slice(blockArea, pageLimit + blockArea).map((n) => (
          <button className={page === n ? 'pageTab pageFocused' : 'pageTab'} key={n} onClick={() => setPage(n)}>  {/* 클릭한 페이지로 바로 이동하는 버튼 이벤트 핸들러 */}
            {n}
          </button>
        ))}
        {/* 오른쪽 버튼 클릭시 현재 페이지에서 1 페이지 이후 페이지로 이동하고, 현재 페이지가 마지막 페이지가 되면 오른쪽 버튼은 비활성화 된다.*/}
        <button className="rightHandle" onClick={nextPageHandler} disabled={page === numAllPages}>
          &gt;
        </button>
      </PageNum>
    </>
  );
}

export default Pagenation;

참고 블로그

0개의 댓글