페이지네이션 (Pagination)

DataGrid 하단 푸터 바에 페이지 번호 네비게이션을 배치하고, 서버 측 페이징 API와 연동하는 방법을 학습합니다.

#pagination#page#server-side-paging#pageSize#totalElements#bottomBarHeight
검토일: 2026-08-23
GitHub
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';

interface MemberRecord {
  memberNo: string;
  name: string;
  email: string;
  membership: string;
  status: '정상' | '휴면' | '차단';
  joinedAt: string;
}

const TOTAL_ELEMENTS = 498;
const PAGE_SIZE = 50;
const names = ['김민준', '이서연', '박지후', '최하윤', '정도현', '한유진'];
const memberships = ['일반', 'Silver', 'Gold', 'VIP'];

const createPageData = (currentPage: number): BGridDataItem<MemberRecord>[] => {
  const startIndex = (currentPage - 1) * PAGE_SIZE;
  const length = Math.max(0, Math.min(PAGE_SIZE, TOTAL_ELEMENTS - startIndex));
  return Array.from({ length }, (_, pageIndex) => {
    const index = startIndex + pageIndex + 1;
    return {
      values: {
        memberNo: `MBR-${String(index).padStart(6, '0')}`,
        name: names[index % names.length],
        email: `member${String(index).padStart(4, '0')}@example.com`,
        membership: memberships[index % memberships.length],
        status: index % 17 === 0 ? '차단' : index % 7 === 0 ? '휴면' : '정상',
        joinedAt: `2026-${String(((index - 1) % 8) + 1).padStart(2, '0')}-${String(((index - 1) % 27) + 1).padStart(2, '0')}`,
      },
    };
  });
};

function PagingExample() {
  const [currentPage, setCurrentPage] = React.useState(1);
  const [columns, setColumns] = React.useState<BGridColumn<MemberRecord>[]>([
    { key: 'memberNo', label: '회원번호', width: 120, align: 'center', sortDisable: true },
    { key: 'name', label: '회원명', width: 110, align: 'center' },
    { key: 'email', label: '이메일', width: 240 },
    { key: 'membership', label: '등급', width: 100, align: 'center' },
    { key: 'status', label: '계정 상태', width: 100, align: 'center' },
    { key: 'joinedAt', label: '가입일', width: 120, align: 'center' },
  ]);
  const data = React.useMemo(() => createPageData(currentPage), [currentPage]);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);

  return (
    <DataGridContainer ref={containerRef}>
      <BGrid<MemberRecord>
        width={width}
        height={height}
        headerHeight={35}
        data={data}
        columns={columns}
        rowKey='memberNo'
        onChangeColumns={(_columnIndex, { columns }) => setColumns(columns)}
        page={{
          currentPage,
          pageSize: PAGE_SIZE,
          totalPages: Math.ceil(TOTAL_ELEMENTS / PAGE_SIZE),
          totalElements: TOTAL_ELEMENTS,
          loading: false,
          onChange: pageNo => setCurrentPage(pageNo),
          displayPaginationLength: 5,
        }}
      />
    </DataGridContainer>
  );
}

export default PagingExample;

1. 언제 사용하며 왜 필요한가요?

가상 스크롤(Virtual Scroll)이 전체 데이터를 한 번에 조회하여 무한 스크롤처럼 탐색할 때 유용하다면, **페이지네이션(Pagination)**은 다음과 같은 경우에 최적의 선택입니다:

  1. 대용량 DB 부하 절감: 백엔드에서 10~50건씩만 분할 조회(LIMIT / OFFSET)하여 네트워크 비용 절감
  2. 명확한 페이지 위치 파악: “3페이지의 5번째 항목”처럼 특정 페이지로 바로 점프할 수 있는 사용자 경험
  3. 인쇄 및 리포트 출력: 특정 페이지 단위로 문서를 출력하거나 검토해야 하는 업무 환경

2. 실무 완성형 예제: 서버 연동 페이지네이션

아래 코드는 페이지 변경 시 비동기 API 요청을 시뮬레이션하여 데이터를 갱신하는 실무 패턴입니다:

import React, { useState, useEffect } from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';

interface MemberItem {
  id: number;
  email: string;
  name: string;
  joinDate: string;
  status: 'ACTIVE' | 'DORMANT' | 'BLOCKED';
}

export default function MemberPaginationGrid() {
  const [currentPage, setCurrentPage] = useState(1); // 1-based page number
  const pageSize = 10;
  const totalElements = 145; // 전체 145건

  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<BGridDataItem<MemberItem>[]>([]);

  // 페이지 변경 시 데이터 로딩 시뮬레이션
  useEffect(() => {
    setLoading(true);
    const timer = setTimeout(() => {
      const startIdx = (currentPage - 1) * pageSize;
      const mockItems: BGridDataItem<MemberItem>[] = Array.from({ length: pageSize }).map((_, i) => {
        const itemIndex = startIdx + i + 1;
        return {
          values: {
            id: itemIndex,
            email: `user_${itemIndex}@example.com`,
            name: `사용자_${itemIndex}`,
            joinDate: '2026-08-01',
            status: itemIndex % 5 === 0 ? 'DORMANT' : 'ACTIVE',
          },
        };
      });
      setData(mockItems);
      setLoading(false);
    }, 200);

    return () => clearTimeout(timer);
  }, [currentPage]);

  const columns: BGridColumn<MemberItem>[] = [
    { key: 'id', label: '회원번호', width: 90, align: 'center' },
    { key: 'name', label: '회원명', width: 140, align: 'center' },
    { key: 'email', label: '이메일 주소', width: 250 },
    { key: 'joinDate', label: '가입일시', width: 130, align: 'center' },
    {
      key: 'status',
      label: '상태',
      width: 100,
      align: 'center',
      itemRender: ({ values }) => (
        <span style={{ color: values.status === 'ACTIVE' ? '#16a34a' : '#d97706', fontWeight: 600 }}>
          {values.status === 'ACTIVE' ? '정상' : '휴면'}
        </span>
      ),
    },
  ];

  return (
    <div>
      <BGrid<MemberItem>
        width={750}
        height={360}
        columns={columns}
        data={data}
        rowKey="id"
        loading={loading}
        // 페이지네이션 구성
        page={{
          currentPage,
          pageSize,
          totalElements,
          totalPages: Math.ceil(totalElements / pageSize),
          loading,
          onChange: (newPage: number) => {
            console.log(`페이지 이동: ${newPage} 페이지`);
            setCurrentPage(newPage);
          },
        }}
        bottomBarHeight={36} // 하단 페이지네이션 바 높이
        headerHeight={34}
        itemHeight={28}
      />
    </div>
  );
}

3. page 속성 명세

interface BGridPage {
  // 현재 페이지 번호 (1부터 시작)
  currentPage?: number;

  // 페이지당 행 개수
  pageSize?: number;

  // 전체 페이지 수. 페이지 번호 UI를 렌더링할 때 필요
  totalPages?: number;

  // 서버 전체 데이터 총 레코드 건수
  totalElements?: number;

  // 페이지 데이터 로딩 상태
  loading?: boolean;

  // 사용자가 페이지 번호나 이전/다음 버튼을 클릭했을 때 콜백
  onChange?: (newPage: number, pageSize?: number) => void;
}

4. 실무 팁 & 주의사항 (Gotchas)

[!IMPORTANT] 1-based 페이지 규칙: 내장 페이지 번호 UI는 첫 페이지를 1로 사용합니다. 백엔드 API가 0부터 시작하는 페이지 인덱스를 요구한다면 요청 시 currentPage - 1, 응답을 화면 상태로 옮길 때 pageIndex + 1로 변환하세요. 페이지 번호 UI를 표시하려면 currentPagetotalPages를 함께 제공해야 합니다.