* 프로그래머스, 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 5기 강의 수강 내용을 정리하는 포스팅.
* 원활한 내용 이해를 위해 수업에서 제시된 자료 이외에, 개인적으로 조사한 자료 등을 덧붙이고 있음.
대량의 데이터를 여러 페이지에 나누어 보여주는 기법
데이터의 로딩 부담을 줄이고 사용자 경험을 향상시키는 핵심 요소 중 하나.
쇼핑몰에는 최소 수 백에서 수 천 단위의 상품 데이터들이 저장되어 있다. 그런데 사용자가 이 쇼핑몰에서 무엇을 파는지 확인하기 위해 '전체 상품 조회'를 한다고 할 때, 모든 상품 데이터를 한 번에 불러오게 되면?
서버에는 감당하기 힘든 부하가 걸리고, 사용자 입장에서도 엄청난 용량의 데이터가 클라이언트로 쏟아지면서 PC 자원을 심각하게 소모하게 된다.
이를 방지하기 위해 사용하는 것이 바로 페이지네이션Pagination 기법.
React.js(프론트엔드) - Express.js(백엔드) - MySQL(데이터베이스)를 사용한다는 가정 하에서 페이지네이션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 연산을 통해 페이지 수를 계산할 수 있다.
(예시) 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를 사용해도 아무 상관 없다.
데이터베이스 테이블에서 특정 열(컬럼)에 대한 검색 성능을 향상시키기 위해 사용하는 자료구조.
책의 맨 뒤에 있는 찾아보기(Index)처럼, 원하는 키워드를 빠르게 찾을 수 있도록 하는 역할을 수행한다.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 10000;
위와 같은 상황에서, 데이터베이스는 OFFSET이 증가할수록 더 많은 행을 건너뛰고 다음 행을 가져와야 한다.
DB는 정렬 기준(created_at)에 맞추어 10,000개의 행을 먼저 스캔(건너뛰기)한 뒤 그 다음 10행을 반환해야하기 때문.
매번 많은 데이터를 순회해야 하고, 테이블 규모가 클수록 응답 시간이 길어질 수 밖에 없다.
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 성능이 저하된다.