React Pagination 컴포넌트 설계 — 말줄임 로직, 1-based 페이지, 접근성

#1-based#aria-current#ellipsis#Next.js#Pagination
/**
 * Pagination Component
 * 
 * Professional pagination with smart ellipsis logic and minimal design.
 * Handles large page counts gracefully and provides smooth interactions.
 */

"use client";

import React from "react";
import { cn } from "@/lib/cn";

export interface PaginationProps {
  /** Current active page (1-based) */
  currentPage: number;
  /** Total number of pages */
  totalPages: number;
  /** Callback when page is changed */
  onPageChange: (page: number) = void;
  /** Whether to show UI when only 1 page exists (default: true) */
  showWhenSinglePage?: boolean;
  /** Extra container className */
  className?: string;
  /** Max buttons to show before using ellipses (default: 7) */
  maxVisible?: number;
}

export function Pagination({
  currentPage,
  totalPages,
  onPageChange,
  showWhenSinglePage = true,
  className = "",
  maxVisible = 7,
}: PaginationProps) {
  const current = Math.min(Math.max(1, currentPage), Math.max(1, totalPages));
  const total = Math.max(1, totalPages);

  if (!showWhenSinglePage && total = 1) return null;

  // Logic to calculate which page numbers to show
  const getPageNumbers = () = {
    const pages: (number  string)[] = [];

    if (total = maxVisible) {
      for (let i = 1; i = total; i++) pages.push(i);
    } else {
      // Always show first page
      pages.push(1);

      const start = Math.max(2, current - 2);
      const end = Math.min(total - 1, current + 2);

      if (start  2) pages.push("...");

      for (let i = start; i = end; i++) {
        pages.push(i);
      }

      if (end  total - 1) pages.push("...");

      // Always show last page
      pages.push(total);
    }
    return pages;
  };

  const pageNumbers = getPageNumbers();

  return (
    nav
      className={cn("flex items-center justify-center gap-1.5 select-none", className)}
      aria-label="Pagination Navigation"
    
      {/* Previous Button */}
      button
        type="button"
        onClick={() = onPageChange(current - 1)}
        disabled={current = 1}
        className={cn(
          "flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-200",
          "border border-slate-200 bg-white text-slate-600 hover:border-slate-900 hover:text-slate-900 hover:bg-slate-50",
          "disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:border-slate-200 disabled:hover:bg-white"
        )}
        aria-label="Go to previous page"
      
        svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
          path d="M15 18l-6-6 6-6" /
        /svg
      /button

      {/* Page Numbers */}
      div className="flex items-center gap-1.5"
        {pageNumbers.map((p, idx) = {
          if (p === "...") {
            return (
              span key={dots-${idx}} className="w-10 h-10 flex items-center justify-center text-slate-400 font-medium"
                
              /span
            );
          }

          const isActive = p === current;

          return (
            button
              key={p}
              type="button"
              onClick={() = onPageChange(p as number)}
              aria-current={isActive ? "page" : undefined}
              className={cn(
                "relative flex items-center justify-center w-10 h-10 rounded-xl text-sm font-bold transition-all duration-300",
                isActive
                  ? "bg-slate-900 text-white shadow-lg shadow-slate-900/20 scale-105 z-10"
                  : "text-slate-500 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200"
              )}
            
              {p}
              {isActive && (
                span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-white opacity-50" /
              )}
            /button
          );
        })}
      /div

      {/* Next Button */}
      button
        type="button"
        onClick={() = onPageChange(current + 1)}
        disabled={current = total}
        className={cn(
          "flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-200",
          "border border-slate-200 bg-white text-slate-600 hover:border-slate-900 hover:text-slate-900 hover:bg-slate-50",
          "disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:border-slate-200 disabled:hover:bg-white"
        )}
        aria-label="Go to next page"
      
        svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
          path d="M9 18l6-6-6-6" /
        /svg
      /button
    /nav
  );
}


1. 왜 Pagination을 따로 만들었는지

블로그유튜브 목록, 관리자 미디어연락처글 목록처럼 페이지네이션이 필요한 곳이 많았는데, 매번 이전/다음 + 페이지 번호 + 말줄임 로직을 반복하기 부담됐습니다. 그래서 현재 페이지총 페이지변경 콜백만 넘기면 번호와 말줄임을 알아서 계산하는 공용 Pagination을 만들기로 했습니다. 1-based로 통일하고, 총 페이지가 1일 때 숨길지 말지 선택할 수 있게 했습니다.

2. 컴포넌트 구성과 설계 방향

페이지 번호 계산
getPageNumbers()에서 (number "...")[]를 만듭니다. total = maxVisible이면 1부터 total까지 전부 넣고, 그렇지 않으면 첫 페이지(1) + 말줄임(필요 시) + 현재 주변 구간(current-2 ~ current+2) + 말줄임(필요 시) + 마지막 페이지(total) 순으로 넣었습니다. 항상 1과 마지막을 보여주어서 끝으로 한 번에 이동할 수 있게 했고, 중간은 현재 기준으로만 노출해 버튼 수를 maxVisible 이하로 유지했습니다.

경계 값 처리
currentPagetotalPages가 API나 쿼리에서 어긋날 수 있어서, 렌더 전에 current는 Math.min(Math.max(1, currentPage), Math.max(1, totalPages))total은 Math.max(1, totalPages)로 클램프했습니다. 이렇게 해서 0 이하나 total 초과 값이 들어와도 깨지지 않고, total이 0이어도 1페이지로 취급해 버튼이 비정상적으로 나오지 않게 했습니다.

접근성
컨테이너는 nav aria-label="Pagination Navigation"로 두어 페이지 이동 영역임을 알렸고, 이전/다음 버튼에는 aria-label="Go to previous page" / "Go to next page"를 넣었습니다. 현재 페이지 버튼에는 aria-current="page"만 넣어서 스크린 리더가 현재 페이지로 읽게 했습니다. 말줄임은 클릭하지 않는 span에 만 넣어서 더 많은 페이지가 있다는 시각적 힌트만 주고, 포커스는 번호이전/다음에만 가도록 했습니다.

스타일
이전/다음은 동일한 스타일로 두고, 첫/끝 페이지에서만 disabled로 막았습니다. 페이지 번호는 비활성은 테두리 없는 호버 스타일, 활성은 bg-slate-900scale-105, 그림자, 아래 작은 점으로 강조해 지금 이 페이지가 한눈에 보이게 했습니다. "use client"는 이 컴포넌트를 쓰는 쪽이 대부분 클라이언트에서 페이지 상태를 갖기 때문에 붙여 두었고, 내부에는 훅이 없어 서버에서 렌더해도 동작합니다.

3. 프롭스가 필요한 이유

currentPage
현재 몇 페이지인지 알려주기 위해 넣었습니다. 1-based로 통일해서 1이 첫 페이지로 맞추고, 위에서 클램프한 current로만 렌더해 잘못된 값이 와도 안전하게 했습니다.

totalPages
총 페이지 수를 알려주기 위해 넣었습니다. 목록 API에서 total_pages나 totalPages를 받아 그대로 넘기면 되고, 0 이하는 내부에서 1로 취급해 아무 버튼도 안 나오는 상황을 막았습니다.

onPageChange
페이지를 바꿀 때 부모가 URL상태를 갱신하려면 필요해서 넣었습니다. 이전/다음/번호 클릭 시 모두 onPageChange(페이지 번호)만 호출하고, 실제 쿼리리패치라우팅은 부모에서 처리하게 했습니다.

showWhenSinglePage
총 페이지가 1일 때 UI를 숨길지 말지 정하려고 넣었습니다. 기본은 true라서 한 페이지만 있어도 페이지네이션 영역이 있다는 걸 보여줄 수 있고, false면 total = 1일 때 null을 반환해 목록 하단을 비우고 싶을 때 씁니다. 블로그유튜브 공개 목록에서는 보통 false, 관리자 목록에서는 true로 두는 식으로 쓰고 있습니다.

maxVisible
한 번에 보여줄 번호 버튼 개수 상한을 두려고 넣었습니다. 기본 7이면 1 4 5 6 10 같은 형태가 되고, 이보다 크면 말줄임으로 중간을 생략합니다. 페이지가 매우 많을 때 버튼이 넘치지 않게 하려고 이렇게 했습니다.

className
네비를 감싸는 nav에만 적용되게 해 두었습니다. 상단/하단 여백, 정렬, 그리드 안에서의 위치 등을 호출하는 쪽에서 조정할 수 있게 하려고 넣었습니다.

4. 사용 용도와 쓰는 방식

  • 관리자 목록: 미디어블로그 글연락처유튜브 목록에서 currentPage는 pagination.pagetotalPages는 API의 totalPages/total_pages를 Math.max(1, ...)로 넘기고, onPageChange에서 쿼리 파라미터를 바꾼 뒤 데이터를 다시 불러옵니다. 한 페이지만 있어도 페이지네이션을 보여주고 싶으면 showWhenSinglePage를 true로 둡니다.

  • 공개 목록: 블로그 포스트 목록유튜브 영상 그리드에서는 URL의 page를 현재 페이지로 쓰고, totalPages는 서버/API에서 계산한 값을 넘깁니다. onPageChange에서 setCurrentPage 또는 라우터로 ?page=n을 바꾸고, 한 페이지만 있을 때는 숨기려고 showWhenSinglePage={false}를 많이 씁니다.

  • 쇼케이스: 한 페이지만 있을 때 보이기/숨기기, 여러 페이지에서 현재 페이지 변경 예시를 같은 컴포넌트로 보여줄 때 사용합니다.

5. 정리

Pagination은 현재 페이지총 페이지만 넘기면 번호와 말줄임을 알아서 계산하고, 이전/다음과 접근성을 한 번에 처리하자는 목적으로 만들었습니다. currentPagetotalPagesonPageChange는 제어를 위해, showWhenSinglePage는 1페이지일 때 UI 노출 여부를 위해, maxVisible은 말줄임 기준을 위해, className은 레이아웃 조정을 위해 넣었습니다. 블로그유튜브 공개/관리자 목록, 미디어연락처글 목록에서 공통으로 쓰이고 있습니다.


52

댓글