Logo

React로 페이지네이션 UI 구현하기

페이지네이션(pagination)은 여러 개의 게시물을 보여주는 웹사이트에서 보통 화면 하단에서 흔히 볼 수 있는 UI입니다. 이번 포스팅에서는 아래와 같이 간단한 페이지네이션(pagination) UI를 구현하는 방법에 대해서 알아보겠습니다.

전체 게시물 목록 구현

우선 단순히 모든 게시물의 목록을 보여주는 React 컴포넌트를 페이지네이션이 없이 구현해볼까요? 게시물 데이터는 JSON Placeholder라는 인터넷에 공개된 API를 통해 가져오도록 하겠습니다.

<Posts/>라는 함수 컴포넌트를 작성하고, useState() 훅 함수로 posts라는 상태를 관리합니다. 그리고 useEffect() 훅 함수를 이용하여 JSON Placeholder API를 비동기로 요청하고 응답받은 게시물 데이터를 posts 상태에 저장합니다.

그 다음, posts 상태를 루프돌면서 각 게시물의 아이디, 제목, 본문을 화면에 출력해보겠습니다.

Posts.jsx
import { useState, useEffect } from "react";
import styled from "styled-components";
import Pagination from "./Pagination";

function Posts() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/posts")
      .then((res) => res.json())
      .then((data) => setPosts(data));
  }, []);

  return (
    <Layout>
      <header>
        <h1>게시물 목록</h1>
      </header>

      <main>
        {posts.map(({ id, title, body }) => (
          <article key={id}>
            <h3>
              {id}. {title}
            </h3>
            <p>{body}</p>
          </article>
        ))}
      </main>
    </Layout>
  );
}

const Layout = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 800px;
  margin: 0 auto;
`;

export default Posts;

위 코드가 잘 이해가 안 되시는 분들은 아래 관련 포스팅을 먼저 읽어보시고 돌아오시기를 추천드립니다.

페이지네이션 알고리즘

꼭 React가 아니라도 어떤 프론트앤드 라이브러리를 사용하든 클라이언트 단에서 페이지네이션을 처리하려면 관련 알고리즘을 이해해두는 것이 좋습니다.

먼저 게시물을 여러 페이지에 나눠서 표시하려면 총 몇 개의 페이지가 필요한지를 알아야하는데요. 총 게시물 수를 페이지 당 표시할 게시물의 수로 나눈 뒤 올림을 하면 몇 개의 페이지가 필요한지를 계산할 수 있습니다.

예를 들어, 총 37개의 게시물이 있고, 페이지 당 10개의 게시물을 표시하려고 한다면, 37 / 10 = 3.7, 여기서 올림하여 결국 4개의 페이지가 필요하게 됩니다. (1~3 페이지에는 10개의 게시물이 표시되고, 4페이지에는 7개의 게시물이 표시가 되겠죠?)

두번째로 알아야할 부분은 현재 페이지 번호를 기준으로 표시해줘야할 게시물들의 범위, 즉, 해당 페이지의 첫 게시물의 위치(index)를 알아야하는데요. 페이지 번호에서 1을 뺀 후에 페이지 당 표시할 게시물의 수를 곱하면 첫 게시물의 위치를 계산할 수 있습니다. (마지막 게시물의 위치는 첫 게시물의 위치에서 단순히 페이지 당 표시할 게시물의 수만 더해주면 되겠죠?)

예를 들어, 위와 동일한 총 37개의 게시물이 있고, 페이지 당 10개의 게시물을 표시되는 상황을 다시 가정해보면…

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

그럼 이 페이지네이션 알고리즘을 숙지한체로 지금부터 페이지네이션 UI를 치근차근 구현해나가보겠습니다.

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

전체 게시물 대신에 현재 페이지에 해당하는 게시물만 보여줄 수 있도록 <Posts/> 컴포넌트를 변경해보겠습니다.

먼저 useState() 훅 함수를 이용하여 페이지 당 게시물 수(limit), 현재 페이지 번호(page)를 상태로 추가합니다. 그리고 위에서 배운 페이지네이션 알고리즘에 따라 첫 게시물의 위치(offset)을 계산합니다.

유저가 페이지 당 표시할 게시물 수를 바꿀 수 있도록 <select> 요소를 추가하였습니다. 마지막으로 자바스크립트 배열의 slice() 함수를 사용하여 첫 게시물부터 마지막 게시물까지만 루프를 돌도록 코드를 변경하였습니다.

src/Posts.jsx
import { useState, useEffect } from "react";
import styled from "styled-components";
import Pagination from "./Pagination";

function Posts() {
  const [posts, setPosts] = useState([]);
  const [limit, setLimit] = useState(10);  const [page, setPage] = useState(1);  const offset = (page - 1) * limit;
  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/posts")
      .then((res) => res.json())
      .then((data) => setPosts(data));
  }, []);

  return (
    <Layout>
      <header>
        <h1>게시물 목록</h1>
      </header>

      <label>        페이지 당 표시할 게시물 수:&nbsp;        <select          type="number"          value={limit}          onChange={({ target: { value } }) => setLimit(Number(value))}        >          <option value="10">10</option>          <option value="12">12</option>          <option value="20">20</option>          <option value="50">50</option>          <option value="100">100</option>        </select>      </label>
      <main>
        {posts.slice(offset, offset + limit).map(({ id, title, body }) => (          <article key={id}>
            <h3>
              {id}. {title}
            </h3>
            <p>{body}</p>
          </article>
        ))}
      </main>
    </Layout>
  );
}

const Layout = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 800px;
  margin: 0 auto;
`;

export default Posts;

자바스크립트 배열의 slice() 함수에 대한 제세한 설명은 관련 포스트를 참고바랍니다.

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

마지막으로 페이지를 이동할 수 있도록 해주는 <Pagination/> 컴포넌트가 필요합니다. <Pagination/> 컴포넌트는 이전 페이지나 다음 페이지 또는 특정 페이지로 바로 이동할 수 있는 많은 버튼으로 구성이 되는데요.

<Pagination/> 컴포넌트는 <Posts/> 컴포넌트로부터 총 게시물 수(total)와 페이지 당 게시물 수(limit) 그리고 현재 페이지 번호(page)를 prop으로 받는데요.

위에서 배운 페이지네이션 알고리즘에 따라 필요한 페이지의 개수(numPages)를 계산한 후 이 페이지의 개수만큼 루프를 돌면서 페이지 번호 버튼을 출력합니다.

페이지 번호 버튼에 클릭 이벤트가 발생하면 prop으로 넘어온 setPage() 함수를 호출하여 부모인 <Posts/> 컴포넌트의 page 상태가 변경되도록 합니다. 그러면 <Posts/> 컴포넌트는 새로운 페이지 번호에 해당하는 게시물 범위를 계산하여 다시 화면을 렌터링할 것입니다.

src/Pagination.jsx
import styled from "styled-components";

function Pagination({ total, limit, page, setPage }) {
  const numPages = Math.ceil(total / limit);

  return (
    <>
      <Nav>
        <Button onClick={() => setPage(page - 1)} disabled={page === 1}>
          &lt;
        </Button>
        {Array(numPages)
          .fill()
          .map((_, i) => (
            <Button
              key={i + 1}
              onClick={() => setPage(i + 1)}
              aria-current={page === i + 1 ? "page" : null}
            >
              {i + 1}
            </Button>
          ))}
        <Button onClick={() => setPage(page + 1)} disabled={page === numPages}>
          &gt;
        </Button>
      </Nav>
    </>
  );
}

const Nav = styled.nav`
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 4px;
  margin: 16px;
`;

const Button = styled.button`
  border: none;
  border-radius: 8px;
  padding: 8px;
  margin: 0;
  background: black;
  color: white;
  font-size: 1rem;

  &:hover {
    background: tomato;
    cursor: pointer;
    transform: translateY(-2px);
  }

  &[disabled] {
    background: grey;
    cursor: revert;
    transform: revert;
  }

  &[aria-current] {
    background: deeppink;
    font-weight: bold;
    cursor: revert;
    transform: revert;
  }
`;

export default Pagination;

페이지 이동을 위한 버튼을 스타일할 때는 여러가지 부분이 고려되야 하는데요. 특히, 유저가 현재 어느 페이지에 있는지를 알 수 있도록 해당 페이지 버튼을 시각적으로 두드러지게 해주는 것이 좋습니다. 뿐만 아니라 현재 페이지가 가리키는 버튼을 aria-current 속성으로 표시해주면 시각 장애 때문에 브라우저 대신에 스크린리더를 사용하는 유저에게도 도움이 될 것입니다. 마지막으로 이전 페이지 버튼은 첫번째 페이지에서, 다음 페이지는 마지막 페이지에서 클릭이 불가능하도록 처리하는 디테일도 신경써줍니다.

페이지네이션이 가능한 게시물 목록 완성

마지막으로 <Navigation/> 컴포넌트를 <Posts/> 컴포넌트에서 불러와서 삽입만해주면 페이지네이션이 가능한 게시물 목록을 얻을 수가 있습니다. 🎉

src/Posts.jsx
import { useState, useEffect } from "react";
import styled from "styled-components";
import Pagination from "./Pagination";

function Posts() {
  const [posts, setPosts] = useState([]);
  const [limit, setLimit] = useState(10);
  const [page, setPage] = useState(1);
  const offset = (page - 1) * limit;

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/posts")
      .then((res) => res.json())
      .then((data) => setPosts(data));
  }, []);

  return (
    <Layout>
      <header>
        <h1>게시물 목록</h1>
      </header>

      <label>
        페이지 당 표시할 게시물 수:&nbsp;
        <select
          type="number"
          value={limit}
          onChange={({ target: { value } }) => setLimit(Number(value))}
        >
          <option value="10">10</option>
          <option value="12">12</option>
          <option value="20">20</option>
          <option value="50">50</option>
          <option value="100">100</option>
        </select>
      </label>

      <main>
        {posts.slice(offset, offset + limit).map(({ id, title, body }) => (
          <article key={id}>
            <h3>
              {id}. {title}
            </h3>
            <p>{body}</p>
          </article>
        ))}
      </main>

      <footer>        <Pagination          total={posts.length}          limit={limit}          page={page}          setPage={setPage}        />      </footer>    </Layout>
  );
}

const Layout = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 800px;
  margin: 0 auto;
`;

export default Posts;

전체 코드

본 포스팅에서 작성한 코드는 아래에서 확인하고 직접 실행해보실 수 있습니다.

마치면서

이상으로 React를 사용해서 어떻게 페이지네이션 UI를 구현할 수 있는지 간단한 예제를 통해서 살펴보았습니다. 실제 프로젝트에서는 이렇게 스스로 구현하기보다는 라이브러리를 쓰는 경우가 많을텐데요. 의외로 알고리즘만 숙지하면 직접 구현하는 게 생각보다 어렵지 않기 때문에 한 번 다루어 보았습니다. 스스로 구현을 해보면 관련 라이브러리를 사용하실 때도 간접적으로 도움이 되실 거라 생각합니다.