Pagination 흐름 이해하기

원민관·2025년 7월 2일

[TIL]

목록 보기
186/202
post-thumbnail

Pagination 📄

1. Pagination이란? 🟢

행정안전부 및 디지털플랫폼정부위원회에서 배포한, 행정기관 및 공공기관이 준수해야 할 디지털 정부서비스 디자인 시스템(KRDS)에서는 Pagination을 다음과 같이 정의한 바 있습니다.

웹사이트에서 게시물이나 상품 목록이 많을 때 한 화면에 모두 보여주지 않고 1·2·3… 같은 페이지 번호를 두고 내용을 나눠 보여주는 방식을 페이지네이션이라고 합니다.

개인 프로젝트 Pullim이 배포 직전 마무리 단계에 있는데요, 홈페이지에서 유저의 질문 데이터를 카드 형식으로 나타낼 생각입니다. 이 과정의 핵심은 Pagination입니다. 하나하나 살펴보시죠.


2. Pagination이 필요한 이유 🟢

2-1. 성능 개선 🟣

Pullim 서비스의 유저가 10만 명이 되었다고 가정해 봅시다. 각 유저가 하루에 질문을 단 한 개만 게시해도, 하루에 쌓이는 질문 데이터가 10만 개가 됩니다.

페이지네이션 없이 홈페이지에서 모든 데이터를 한꺼번에 가져오면 어떻게 될까요? 소위 "서버가 터지는" 사태가 발생하고, 유저는 빈 브라우저 화면만 보게 될 것입니다. 동작을 하지 않는 서비스를 사용할 유저는 없겠죠.

데이터를 작은 단위로 나누어서 불러오면, 각 요청에 대한 응답이 훨씬 빨라지게 될 것입니다.

2-2. UX 개선 🟣

'한 번에 보이는 정보량을 줄이는 것'은 UX 개선 측면에서 굉장히 중요한 주제입니다. UX를 개선한다는 것이 어떤 의미일까요? 사용자가 동일한 결과를 얻는 데 해야 하는 행동이 줄어들어야 함을 의미합니다. 페이지네이션을 적용하지 않고 홈페이지에서 질문 데이터를 200개 보여준다면, 사용자는 계속해서 스크롤 다운을 해야겠죠.

페이지네이션을 적용하면 '페이지 번호 버튼'이나 '이전/다음 버튼'을 통해 원하는 위치를 쉽게 넘나들 수 있게 됩니다. 탐색이 편리해지는 것이죠. 필요한 정보를 찾기 위해 긴 목록을 스크롤 하지 않아도 된다는 뜻입니다.

이제부터는 Frontend와 Backend에서 어떻게 페이지네이션을 구현할 수 있는지, 코드를 통해 살펴보고자 합니다.


3. Frontend 🟢

3-1. 기본 동작 🟣

비단 페이지네이션 뿐만 아니라, 프론트엔드에서 특정 기능을 구현할 때 가장 중요한 점은 무엇일까요? 다름 아닌 '상태(데이터)'입니다. 어떤 재료가 필요한 지를 아는 것이 가장 중요합니다.

당연한 얘기일 수 있지만 요즘 백엔드에서, 데이터 모델링을 어떻게 하면 더 잘할 수 있을지 고민 중입니다. 그래서 프론트엔드에서도 코드의 동작 순서를 따라가되, 데이터를 중심으로 설명하고자 합니다.

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

useEffect 훅은 컴포넌트가 마운트 될 때 즉시 실행됩니다. 즉각적으로 fetchItems(currentPage) 함수를 실행하게 되죠. 이때 의존성 배열에 currentPage가 작성되어 있는 것을 확인할 수 있는데요, currentPage 상태가 변화하면 fetchItems() 함수를 다시 실행하는 역할을 수행합니다.

const [currentPage, setCurrentPage] = useState(1);

currentPage는 최근 페이지를 의미합니다. setCurrentPage는 최근 페이지를 업데이트해주는 역할을 합니다. 사용자가 최초로 페이지에 도달했을 때에는 기본값인 1이 적용됩니다. 즉 최초에 fetchItems() 함수에 전달되는 값은 1입니다.

이번에는 컴포넌트 마운트 즉시 실행되는 fetchItems() 함수의 세부적인 구현에 대해 살펴보겠습니다.

  const fetchItems = async (page: number) => {
    try {
      console.log(
        `[Client] Fetching items - page: ${page}, limit: ${ITEMS_PER_PAGE}`
      );

      const res = await axios.get<ItemResponse>("http://localhost:3000/items", {
        params: {
          page,
          limit: ITEMS_PER_PAGE,
        },
      });

      setItems(res.data.data);
      setCurrentPage(res.data.page);
      setTotalPages(res.data.totalPages);

      console.log(`[Client] Received ${res.data.data.length} items`);
      console.log(`[Client] Total pages: ${res.data.totalPages}`);
    } catch (err) {
      console.error("[Client] Failed to fetch items:", err);
    }
  };

컴포넌트가 마운트 되는 순간 http://localhost:3000/items 경로로 데이터를 달라는 요청을 수행합니다. 요청을 보낼 때 params도 같이 보냅니다.

const ITEMS_PER_PAGE = 10;
const [totalPages, setTotalPages] = useState(1);

페이지 당 리스트의 개수는 10개로 설정했습니다. 이전에, fetchItems 함수에는 currentPage가 전달된다고 설명했습니다. 단 최초에는 초깃값인 1이 전달되는 것이죠.

서버에 '현재 페이지'와 '페이지 당 리스트 개수'를 url에 붙여서 페이지에 해당하는 데이터를 달라고 요청하는 것입니다. 이후 set 함수들을 통해 frontend의 상태를 내부적으로 업데이트합니다.

3-2. 버튼 동작 🟣

<button
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
disabled={currentPage === 1}
>
이전
</button>

'이전' 버튼입니다. p는 현재 상태 값, 즉 currentPage입니다. '이전' 버튼을 클릭하면 현재 페이지에서 1을 뺀 페이지로 현재 페이지를 업데이트합니다. 현재 페이지가 1이 되는 순간 동작하지 않도록 설정했습니다.

currentPage의 상태 값이 변경되었으니 fetchItems() 함수를 다시 실행하며 변경된 페이지 값을 전달하겠죠.

<button
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
disabled={currentPage === totalPages}
>
다음
</button>

'다음' 버튼입니다. '이전' 버튼과는 반대로 현재 페이지에 1을 추가하는 기능을 수행합니다. 전체 페이지에 도달하면 버튼이 disabled 되도록 설정했습니다.

{Array.from({ length: totalPages }, (_, i) => (
<button
key={i + 1}
onClick={() => setCurrentPage(i + 1)}
style={{
margin: "0 5px",
fontWeight: currentPage === i + 1 ? "bold" : "normal",
}}
>
{i + 1}
</button>
))}

'숫자' 버튼도 결국에는 currentPage를 업데이트하는 역할을 수행합니다. 다만 i + 1이 적용된 모습을 확인할 수 있는데요, 배열의 인덱스는 0부터 시작하기 때문입니다. 즉 1번 버튼을 클릭하면 i는 0이기 때문에, 실제 페이지를 전달할 때에는 i + 1을 전달해야 하는 것이죠.

3-3. 전체 코드와 핵심 요약 🟣

import { useEffect, useState } from "react";
import axios from "axios";

interface ItemResponse {
  data: string[];
  total: number;
  page: number;
  totalPages: number;
}

const ITEMS_PER_PAGE = 10;

const PaginationWithAPI = () => {
  const [items, setItems] = useState<string[]>([]);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);

  const fetchItems = async (page: number) => {
    try {
      console.log(
        `[Client] Fetching items - page: ${page}, limit: ${ITEMS_PER_PAGE}`
      );

      const res = await axios.get<ItemResponse>("http://localhost:3000/items", {
        params: {
          page,
          limit: ITEMS_PER_PAGE,
        },
      });

      setItems(res.data.data);
      setCurrentPage(res.data.page);
      setTotalPages(res.data.totalPages);

      console.log(`[Client] Received ${res.data.data.length} items`);
      console.log(`[Client] Total pages: ${res.data.totalPages}`);
    } catch (err) {
      console.error("[Client] Failed to fetch items:", err);
    }
  };

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

  return (
    <div style={{ padding: 20 }}>
      <h2>📦 API 페이지네이션</h2>
      <ul>
        {items.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>

      <div style={{ marginTop: 20 }}>
        <button
          onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
          disabled={currentPage === 1}
        >
          이전
        </button>

        {Array.from({ length: totalPages }, (_, i) => (
          <button
            key={i + 1}
            onClick={() => setCurrentPage(i + 1)}
            style={{
              margin: "0 5px",
              fontWeight: currentPage === i + 1 ? "bold" : "normal",
            }}
          >
            {i + 1}
          </button>
        ))}

        <button
          onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
          disabled={currentPage === totalPages}
        >
          다음
        </button>
      </div>
    </div>
  );
};

export default PaginationWithAPI;

위 코드는 현재 페이지 상태를 기준으로 API에서 데이터를 받아와 보여주며, 이전·다음 버튼과 페이지 번호 버튼으로 페이지를 이동할 수 있게 구현되어 있습니다. 버튼 클릭 시 최신 상태를 반영해 페이지가 변경됩니다.


4. Backend 🟢

Frontend에서 Backend로 보내준 정보는 '현재 페이지'와 '페이지 당 리스트 개수'였습니다. Backend에서의 처리를 살펴보겠습니다.

4-1. Controller 🟣

import { Controller, Get, Query, Logger } from '@nestjs/common';
import { ItemsService } from './items.service';

@Controller('items')
export class ItemsController {
  private readonly logger = new Logger(ItemsController.name);

  constructor(private readonly itemsService: ItemsService) {}

  @Get()
  getPaginatedItems(@Query('page') page = '1', @Query('limit') limit = '10') {
    const pageNum = Math.max(parseInt(page, 10) || 1, 1);
    const limitNum = Math.max(parseInt(limit, 10) || 10, 1);

    this.logger.log(`Received GET /items?page=${pageNum}&limit=${limitNum}`);

    return this.itemsService.getItems(pageNum, limitNum);
  }
}

쿼리 파라미터로 받아온 '현재 페이지'와 '페이지 당 리스트 개수'를 파싱합니다.

page와 limit는 문자열로 들어오기에, parseInt를 통해 10진수 정수로 변환합니다. 10진수 정수로 파싱한 데이터를 itemsService의 getItems 메서드에 전달합니다.

4-2. Service 🟣

import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class ItemsService {
  private readonly logger = new Logger(ItemsService.name);
  private items: string[] = [];

  constructor() {
    for (let i = 1; i <= 100; i++) {
      this.items.push(`Item ${i}`);
    }
    this.logger.log(`Initialized with ${this.items.length} items`);
  }

  getItems(page: number, limit: number) {
    const total = this.items.length;
    const start = (page - 1) * limit;
    const end = start + limit;
    const data = this.items.slice(start, end);

    this.logger.log(
      `Pagination request - page: ${page}, limit: ${limit}, range: [${start}, ${end})`,
    );

    return {
      data,
      total,
      page,
      totalPages: Math.ceil(total / limit),
    };
  }
}

가장 먼저, 1부터 100까지 숫자를 붙인 문자열 "Item 1", "Item 2" ... "Item 100"을 items 배열에 넣습니다. 실제로는 메모리가 아니라 Database에 저장되어야 하는 값입니다.

다음은 데이터에 대한 설명입니다.

  1. total: 전체 아이템의 총 개수(100개로 설정)
  2. start: 현재 페이지에서 보여줄 아이템의 시작 인덱스
    (2페이지라면 start 값은 10, 인덱스가 10이기에 Item 11부터 제시)
  3. end: 현재 페이지에서 보여줄 아이템의 끝 인덱스
    (2페이지라면 end 값은 20, 실제 마지막 인덱스는 19여야 맞지만 slice 메서드는 end 값을 포함하지 않기에 end는 20이 되어야 함)
  4. data: items 배열에서 start부터 end 바로 전까지 잘라낸 현재 페이지에 해당하는 아이템 목록

파싱한 데이터를 최종적으로 반환합니다.


5. 최종 요약 🟢

컴포넌트가 마운트 되면 currentPage 상태(초기값 1)가 useEffect를 통해 fetchItems 함수를 실행시키고, 이 함수는 현재 페이지 번호와 페이지당 아이템 수(10개)를 쿼리 파라미터로 Backend API에 요청을 보냅니다.

Backend의 Controller에서는 이 파라미터들을 파싱하여 Service로 전달하고, Service에서는 전체 아이템 배열에서 (page-1) * limit부터 start + limit까지의 범위를 계산해 해당 페이지의 데이터를 slice로 추출한 후, 데이터와 함께 총 페이지 수, 현재 페이지 등의 메타데이터를 Frontend로 반환합니다.

Frontend는 받은 데이터로 상태를 업데이트하여 화면에 렌더링하고, 사용자가 이전/다음 버튼이나 페이지 번호 버튼을 클릭하면 setCurrentPage로 페이지 상태를 변경하여 다시 동일한 사이클을 반복합니다.


6. 마무리 🟢

“바퀴를 다시 발명하지 마라”는 말이 있습니다. 이미 잘 만들어진 것을 굳이 다시 만들 필요는 없다는, 프로그래밍 세계의 오래된 격언이죠. 하지만 공부할 때에는 바퀴를 재발명 해야 합니다. 클론 코딩은 좋은 학습 방법 중 하나라면서, 바퀴를 다시 발명하지 말라는 것은 어불성설이죠.

만약 제가 가구 공예사라면, 이케아 가구를 조립해 온 사람보다, 직접 목재를 깎아보고 실패해 본 사람을 동료로 더 신뢰할 것입니다. 어디까지 성공해 봤냐는 질문으로는 그 사람의 기술적 수준을 파악하기 어렵습니다. 어디까지 실패해 봤냐는 질문이 더 본질적입니다.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글