Day 27 - 페이지네이션Pagination

이유승·2024년 12월 13일
0

* 프로그래머스, 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 5기 강의 수강 내용을 정리하는 포스팅.

* 원활한 내용 이해를 위해 수업에서 제시된 자료 이외에, 개인적으로 조사한 자료 등을 덧붙이고 있음.

1. 페이지네이션Pagination?

  • 대량의 데이터를 여러 페이지에 나누어 보여주는 기법

  • 데이터의 로딩 부담을 줄이고 사용자 경험을 향상시키는 핵심 요소 중 하나.

  • 쇼핑몰에는 최소 수 백에서 수 천 단위의 상품 데이터들이 저장되어 있다. 그런데 사용자가 이 쇼핑몰에서 무엇을 파는지 확인하기 위해 '전체 상품 조회'를 한다고 할 때, 모든 상품 데이터를 한 번에 불러오게 되면?

  • 서버에는 감당하기 힘든 부하가 걸리고, 사용자 입장에서도 엄청난 용량의 데이터가 클라이언트로 쏟아지면서 PC 자원을 심각하게 소모하게 된다.

  • 이를 방지하기 위해 사용하는 것이 바로 페이지네이션Pagination 기법.



2. Pagination 구현

  • React.js(프론트엔드) - Express.js(백엔드) - MySQL(데이터베이스)를 사용한다는 가정 하에서 페이지네이션Pagination을 어떻게 구현할 수 있는지 알아보려 한다.

  • 우선, 각 파트에서 필요한 핵심 요소는 아래와 같다.



React.js(클라이언트 측):

  • 현재 페이지 번호를 상태로 관리
  • 페이지 전환(다음 페이지, 이전 페이지, 페이지 번호 클릭 등)에 따라 백엔드에 새로운 요청 보내기
  • 서버로부터 받은 데이터와 총 페이지 수 등의 메타 정보 활용
  • useState 및 useEffect로 현재 페이지 관리
  • 백엔드 API에서 data, totalPages 받아와서 페이지네이션 UI 렌더링



Express.js(서버 측):

  • 클라이언트로부터 현재 페이지, 페이지 당 보여줄 데이터 개수를 파라미터로 받고,
  • 해당 범위에 맞는 데이터를 MySQL에서 LIMIT/OFFSET을 통해 가져오기
  • 총 데이터 개수와 총 페이지 수 등의 메타 정보도 함께 응답
  • page, limit 파라미터 처리
  • COUNT(*) 및 LIMIT OFFSET 쿼리를 통한 데이터 슬라이싱
  • currentPage, totalPages 메타 정보 함께 반환



MySQL(데이터베이스):

  • 전체 데이터 행(row) 수를 신속하게 조회할 수 있는 쿼리 작성
  • LIMIT과 OFFSET을 활용한 효율적인 데이터 슬라이싱
  • 인덱싱을 통한 쿼리 속도 최적화
  • 인덱스를 통한 쿼리 최적화
  • LIMIT, OFFSET 활용



3. Pagination 예제



데이터베이스에서..

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  content TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
  • 가상의 포스팅 테이블이 위와 같이 되어있다고 가정해보자.

  • 페이지네이션에 필요한 핵심 쿼리는 크게 두 가지. 전체 게시글 수 조회 / 특정 페이지의 게시글 목록 조회

SELECT COUNT(*) AS total FROM posts;
SELECT id, title, created_at 
FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 0;
  • LIMIT 10은 한 번에 10개씩 가져온다는 의미, OFFSET 0은 첫 페이지를 의미. 2페이지라면 OFFSET 10, 3페이지라면 OFFSET 20 이런 식으로 계산할 수 있다.

  • 전체 페이지 수를 계산하려면, total 값을 받아와서 total / limit 연산을 통해 페이지 수를 계산할 수 있다.



서버에서..

  • 클라이언트로부터 현재 페이지(page)와 페이지 당 표시할 데이터 수(limit)를 Query Parameter나 경로 파라미터를 통해 전달받아야 한다.

(예시) GET /api/posts?page=2&limit=10

// routes/posts.js
const express = require('express');
const router = express.Router();
const pool = require('../db'); // mysql 연결 풀 (예: mysql2/promise 기반)

router.get('/', async (req, res) => {
  try {
    const page = parseInt(req.query.page, 10) || 1;
    const limit = parseInt(req.query.limit, 10) || 10;
    const offset = (page - 1) * limit;

    // 1. 전체 개수 조회
    const [[{ total }]] = await pool.query('SELECT COUNT(*) AS total FROM posts');

    // 2. 페이지네이션된 데이터 조회
    const [rows] = await pool.query(`
      SELECT id, title, created_at
      FROM posts
      ORDER BY created_at DESC
      LIMIT ? OFFSET ?`, [limit, offset]);

    // 총 페이지 수 계산
    const totalPages = Math.ceil(total / limit);

    // 응답 예: { data: [...], currentPage: page, totalPages, total }
    res.json({
      data: rows,
      currentPage: page,
      totalPages,
      total
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
  • page, limit 파라미터는 유효성 체크를 하고 기본값을 설정.

  • COUNT(*)로 전체 레코드 수를 얻은 뒤 rows에서 LIMIT/OFFSET으로 필요한 부분만 가져온다

  • 응답 시 전체 페이지 수(totalPages)와 현재 페이지(currentPage), 전체 개수(total)를 함께 반환.
    - 이렇게 해야 프론트엔드에서 Pagination 기능과 UI를 구현할 수 있다.



클라이언트에서..

import React, { useEffect, useState } from 'react';

function PostList() {
  const [posts, setPosts] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);

  // 페이지 변경 시 데이터를 다시 불러오는 함수
  const fetchPosts = async (page = 1) => {
    const response = await fetch(`/api/posts?page=${page}&limit=10`);
    const result = await response.json();
    setPosts(result.data);
    setCurrentPage(result.currentPage);
    setTotalPages(result.totalPages);
  };

  useEffect(() => {
    fetchPosts(currentPage);
  }, [currentPage]);

  const handlePageClick = (pageNumber) => {
    setCurrentPage(pageNumber);
  };

  return (
    <div>
      <h1>게시글 리스트</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <strong>{post.title}</strong>
            <span>{new Date(post.created_at).toLocaleString()}</span>
          </li>
        ))}
      </ul>

      {/* 페이지네이션 UI */}
      <div style={{ marginTop: '20px' }}>
        {Array.from({ length: totalPages }, (_, i) => i + 1).map(pageNum => (
          <button
            key={pageNum}
            onClick={() => handlePageClick(pageNum)}
            style={{
              margin: '0 5px',
              backgroundColor: currentPage === pageNum ? 'lightblue' : 'white'
            }}
          >
            {pageNum}
          </button>
        ))}
      </div>
    </div>
  );
}

export default PostList;
  • 현재 페이지 상태(currentPage)를 useState 등을 사용해 관리.

  • 페이지 변경 시마다 백엔드(/api/posts?page={currentPage}&limit=10)를 호출.

  • 받은 data, totalPages, currentPage 정보를 가지고 화면에 목록과 페이지네이션 UI를 렌더링.

  • useEffect를 통해 currentPage 변경 시 자동으로 해당 페이지 데이터를 요청.

  • 페이지 번호 버튼을 렌더링할 때 현재 페이지에는 시각적인 표시.

  • fetch를 통해 백엔드 API에 페이지 번호를 매개변수로 요청.
    - 당연하지만 Axios를 사용해도 아무 상관 없다.



4. 인덱싱Indexing?

  • 데이터베이스 테이블에서 특정 열(컬럼)에 대한 검색 성능을 향상시키기 위해 사용하는 자료구조.

  • 책의 맨 뒤에 있는 찾아보기(Index)처럼, 원하는 키워드를 빠르게 찾을 수 있도록 하는 역할을 수행한다.

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 10000;
  • 위와 같은 상황에서, 데이터베이스는 OFFSET이 증가할수록 더 많은 행을 건너뛰고 다음 행을 가져와야 한다.

  • DB는 정렬 기준(created_at)에 맞추어 10,000개의 행을 먼저 스캔(건너뛰기)한 뒤 그 다음 10행을 반환해야하기 때문.

  • 매번 많은 데이터를 순회해야 하고, 테이블 규모가 클수록 응답 시간이 길어질 수 밖에 없다.



Index!

CREATE INDEX idx_created_at ON posts(created_at);

혹은..

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255),
  content TEXT,
  created_at DATETIME,
  INDEX idx_created_at (created_at)
);
  • 이렇게 정렬 기준에 인덱스를 설정해주면..

  • 데이터베이스는 created_at 값을 기준으로 정렬된 인덱스 구조를 활용해 OFFSET 위치까지 신속히 도달할 수 있다.

  • 모든 테이블을 하나하나 살펴보지 않고, 원하는 영역으로 바로 건너뛰게되는 것.



주의점..

  • 인덱스를 추가하면 읽기(SELECT) 성능은 향상되지만, INSERT/UPDATE/DELETE 연산 시 인덱스도 함께 갱신해야 하므로 쓰기 성능이 떨어진다.

  • 인덱스도 하나의 연산이기 때문에, 인덱스가 많을 수록 DB 성능이 저하된다.

profile
프론트엔드 개발자를 준비하고 있습니다.

0개의 댓글