UX를 고려한 페이지네이션 설계 및 구현

UX 관점에서 본 좋은 페이지네이션 설계

웹사이트에서 페이지네이션(pagination)은 데이터를 여러 페이지로 나누어 사용자에게 제공하는 중요한 UI 요소입니다. 특히 많은 데이터를 효율적으로 탐색할 수 있도록 돕는 UX 설계가 필요합니다. 좋은 페이지네이션은 다음과 같은 특징을 가집니다.

1.1 현재 위치를 명확하게 보여주기

사용자는 현재 페이지가 몇 번째인지 한눈에 파악할 수 있어야 합니다.

  • 현재 페이지 강조: 현재 페이지 버튼을 시각적으로 구분(예: 색상 변경)

  • 페이지 그룹 표시: 현재 페이지가 속한 블록을 보여주어 사용자가 전체적인 맥락을 이해하도록 함

1.2 빠른 이동 경로 제공

사용자가 특정 페이지로 빠르게 이동할 수 있어야 합니다.

  • 처음, 마지막 페이지 이동 버튼

  • 일정 블록 단위(예: 5~6개)로 점프할 수 있는 기능

  • 너무 많은 페이지가 한꺼번에 보이지 않도록, 적절한 개수만 표시

1.3 직관적인 인터랙션 요소 제공

  • 비활성화된 버튼(예: 첫 페이지에서 '이전' 버튼)은 클릭할 수 없도록 설정

  • 마우스를 올렸을 때 피드백(hover 효과) 제공

1.4 반응형 디자인 고려

  • 모바일에서도 보기 편하도록 크기와 간격 조절

결과물

  • 어떻게 구현했는지 설명하기에 앞서 결과물 먼저 보여드리겠습니다.

커스텀 페이지네이션 훅

위의 UX 원칙을 바탕으로 재사용 가능한 커스텀 훅을 만들었습니다.

1 usePagination의 역할

이 훅은 다음과 같은 기능을 담당합니다.

  1. 현재 페이지와 최대/최소 페이지 관리

  2. 한 번에 보여줄 페이지 개수(블록 단위) 관리

  3. 이전/다음 블록으로 이동 기능 제공

  4. 특정 페이지로 이동 기능 제공

2 usePagination 코드 및 상세 설명

타입 및 반환값 정의

type Page = number | 'nextBlock' | 'prevBlock';
  • Page 타입은 number 또는 'nextBlock' | 'prevBlock' 중 하나

  • 숫자는 실제 페이지 번호를 나타냄

  • 'nextBlock''prevBlock'은 이전/다음 페이지 블록을 의미

PaginationHookReturn 인터페이스 정의

export interface PaginationHookReturn {
  currentPage: number;
  visiblePages: (number | 'nextBlock' | 'prevBlock')[];
  minPage: number;
  maxPage: number;
  handleClickPage: (page: number) => void;
  handleClickPrevPage: () => void;
  handleClickNextPage: () => void;
  handleClickPrevPages: () => void;
  handleClickNextPages: () => void;
  setMaxPage: (newMaxPage: number) => void;
  setMinPage: (newMinPage: number) => void;
}
  • currentPage: 현재 선택된 페이지 번호

  • visiblePages: 현재 화면에 보이는 페이지 목록 (예: [1, 2, 3, 'nextBlock', 10])

  • maxPage: 최소 페이지 번호

  • handleClickPage(page): 특정 페이지를 클릭했을 때 실행

  • handleClickPrevPage(): 이전 페이지로 이동

  • handleClickNextPage(): 다음 페이지로 이동

  • handleClickPrevPages(): 이전 블록으로 이동

  • handleClickNextPages(): 다음 블록으로 이동

  • setMaxPage(newMaxPage): maxPage를 업데이트

  • setMinPage(newMinPage): minPage를 업데이트하는

훅의 기본 구조 및 블록 크기 설정

export const usePagination = (
  initialMinPage: number,
  initialMaxPage: number,
  blockSize: number = 7,
): PaginationHookReturn => {
  const END_BLOCK_SIZE = blockSize - 1;
  const MIDDLE_BLOCK_SIZE = blockSize - 4;
  • initialMinPageinitialMaxPage를 받아 페이지 범위를 설정

  • blockSize는 한 번에 보이는 페이지 개수(기본값 7)로, 이전/다음페이지 UI를 제외한 블록 개수를 의미

  • END_BLOCK_SIZE = blockSize - 1: 양 끝 페이지에 위치할 때 가용 페이지 개수

  • MIDDLE_BLOCK_SIZE = blockSize - 4: 중간 블록에서의 가용 페이지 개수

상태 선언

  const [maxPage, setMaxPage] = useState(initialMaxPage);
  const [minPage, setMinPage] = useState(initialMinPage);
  const [currentPage, setCurrentPage] = useState(minPage);
  const [visiblePages, setVisiblePages] = useState<Page[]>([]);
  • maxPage: 최대 페이지

  • minPage: 최소 페이지

  • currentPage: 현재 페이지 (기본적으로 minPage에서 시작)

  • visiblePages: 현재 보이는 페이지 목록 (useEffect에서 업데이트됨)

페이지 목록 업데이트 로직

  useEffect(() => {
    if (currentPage < minPage || currentPage > maxPage) return;
    setVisiblePages(getBlockPages());
  }, [currentPage, maxPage]);
  • currentPage, maxPage가 변경될 때 실행됨

  • 현재 페이지가 최소/최대 페이지 범위를 벗어나면 아무 작업도 하지 않음

  • getBlockPages()를 호출하여 visiblePages를 업데이트함

페이지 블록을 계산하는 함수들

  const getBlockPages = () => {
    if (maxPage <= blockSize) return getAllPages();
    if (currentPage <= minPage + MIDDLE_BLOCK_SIZE) return getFirstBlock();
    else if (currentPage >= maxPage - MIDDLE_BLOCK_SIZE) return getLastBlock();
    else return getMiddleBlock();
  };
  • 전체 페이지 개수가 blockSize 이하이면 getAllPages()를 호출

  • 현재 페이지가 minPage 근처면 getFirstBlock() 호출

  • 현재 페이지가 maxPage 근처면 getLastBlock() 호출

  • 그 외의 경우는 getMiddleBlock() 호출

getAllPages(): 모든 페이지 반환

  const getAllPages = (): Page[] => {
    return Array.from({ length: maxPage - minPage + 1 }, (_, i) => minPage + i);
  };
  • [1, 2, 3, 4, 5, 6] 같은 전체 페이지 목록을 반환

getFirstBlock(): 처음 페이지 블록 생성

  const getFirstBlock = (): Page[] => {
    const pages = Array.from({ length: Math.min(maxPage, END_BLOCK_SIZE - 1) }, (_, i) => minPage + i);
    if (maxPage - minPage > END_BLOCK_SIZE) return [...pages, 'nextBlock', maxPage];
    return pages;
  };
  • [1, 2, 3, 4, 5, ..., 51] 형태로 반환

getLastBlock(): 마지막 페이지 블록 생성

  const getLastBlock = (): Page[] => {
    const pages = Array.from({ length: END_BLOCK_SIZE - 1 }, (_, i) => maxPage - (END_BLOCK_SIZE - 2) + i);
    if (maxPage - minPage > END_BLOCK_SIZE) return [minPage, 'prevBlock', ...pages];
    return pages;
  };
  • [1, ..., 47, 48, 49, 50, 51] 형태로 반환

getMiddleBlock(): 중간 페이지 블록 생성

  const getMiddleBlock = (): Page[] => {
    const start = Math.max(minPage, currentPage - Math.floor(MIDDLE_BLOCK_SIZE / 2));
    const end = Math.min(maxPage, currentPage + Math.floor(MIDDLE_BLOCK_SIZE / 2));
    const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
    return [minPage, 'prevBlock', ...pages, 'nextBlock', maxPage];
  };ㅇ
  • [1, ..., 9, 10, 11, ..., 51] 형태로 반환

이벤트 핸들러 함수들

  const handleClickPage = (page: number) => setCurrentPage(page);
  const handleClickPrevPage = () => setCurrentPage((prev) => Math.max(minPage, prev - 1));
  const handleClickNextPage = () => setCurrentPage((prev) => Math.min(maxPage, prev + 1));
  const handleClickPrevPages = () => {
    if (currentPage > maxPage - END_BLOCK_SIZE + 1) setCurrentPage(Math.max(minPage, maxPage - END_BLOCK_SIZE));
    else setCurrentPage((prev) => Math.max(minPage, prev - MIDDLE_BLOCK_SIZE));
  };
  const handleClickNextPages = () => {
    if (currentPage < minPage + END_BLOCK_SIZE - 1) setCurrentPage(Math.min(maxPage, minPage + END_BLOCK_SIZE));
    else setCurrentPage((prev) => Math.min(maxPage, prev + MIDDLE_BLOCK_SIZE));
  };
  • 특정 페이지 선택, 이전/다음 페이지, 이전/다음 블록 이동 기능을 담당

페이지네이션 컴포넌트

페이지네이션 버튼

interface PaginationButtonProps extends PropsWithChildren {
  isActive?: boolean; // 현재 선택된 버튼인지 여부
  isDisAbled?: boolean; // 버튼이 비활성화(클릭 불가능) 상태인지 여부
  handleClick?: () => void; // 버튼 클릭 이벤트 핸들러
}
function PaginationButton({ isActive = false, isDisAbled = false, children, handleClick }: PaginationButtonProps) {
  return (
    <li>
      <button
        disabled={isDisAbled} // 버튼 비활성화 여부
        onClick={handleClick} // 클릭 이벤트 핸들러
        className={cn(
          {
            [styles.paginationButtonActive]: isActive && !isDisAbled,
            [styles.paginationButtonInactive]: !isActive && !isDisAbled,
            [styles.paginationButtonDisabled]: isDisAbled,
          },
          styles.paginationButton,
        )}
      >
        {children}
      </button>
    </li>
  );
}
  • isDisAbledtrue면 버튼을 클릭할 수 없도록 disabled 속성을 추가

  • className동적으로 설정 (cn 함수 활용)

    • isActivetrue이면 현재 선택된 버튼임을 나타내는 스타일 적용

    • isDisAbledtrue이면 버튼을 비활성화 해야 하므로 비활성화 버튼의 스타일 적용

페이지네이션 UI

interface PaginationProps {
  nextBlockChar?: string; // 다음 블록 표시 문자 (기본값: '...')
  prevBlockChar?: string; // 이전 블록 표시 문자 (기본값: '...')
  nextPageChar?: string; // 다음 페이지 버튼 문자 (기본값: '>')
  prevPageChar?: string; // 이전 페이지 버튼 문자 (기본값: '<')
  paginationTools: PaginationHookReturn; // usePagination 훅에서 반환된 툴 
}
  • paginationToolsusePagination 훅에서 받아와 페이지네이션 상태를 제어

function Pagination({
  nextBlockChar = '...',
  prevBlockChar = '...',
  nextPageChar = '>',
  prevPageChar = '<',
  paginationTools,
}: PaginationProps) {
  • 기본값을 설정하여 유연한 커스터마이징이 가능하도록 구현

  const {
    visiblePages, // 현재 표시해야 하는 페이지 목록
    currentPage, // 현재 페이지
    minPage, // 최소 페이지
    maxPage, // 최대 페이지
    handleClickPrevPage, // 이전 페이지 이동
    handleClickPrevPages, // 이전 블록 이동
    handleClickNextPage, // 다음 페이지 이동
    handleClickNextPages, // 다음 블록 이동
    handleClickPage, // 특정 페이지 클릭
  } = paginationTools;
  • paginationTools에서 페이지 이동과 관련된 함수 및 상태를 구조 분해 할당하여 사용

이전 페이지 버튼

      <PaginationButton key="prevPage" isDisAbled={currentPage === minPage} handleClick={handleClickPrevPage}>
        {prevPageChar}
      </PaginationButton>
  • 현재 페이지가 minPage이면 비활성화

  • < 아이콘을 클릭하면 handleClickPrevPage() 실행

페이지 목록 버튼

      {visiblePages.map((page) => {
        if (page === 'prevBlock')
          return (
            <PaginationButton key="prevBlock" handleClick={handleClickPrevPages}>
              {prevBlockChar}
            </PaginationButton>
          );
         
  • 이전 블록 이동 버튼 (...)을 나타냄

  • "prevBlock" 값이 있으면 handleClickPrevPages() 실행

        else if (page === 'nextBlock') {
          return (
            <PaginationButton key="nextBlock" handleClick={handleClickNextPages}>
              {nextBlockChar}
            </PaginationButton>
          );
        }
  • 다음 블록 이동 버튼 (...)을 나타냄

  • "nextBlock" 값이 있으면 handleClickNextPages() 실행

        else
          return (
            <PaginationButton isActive={page === currentPage} handleClick={() => handleClickPage(page)} key={page}>
              {page}
            </PaginationButton>
          );
      })}
  • 각 페이지 버튼 (숫자 버튼)을 나타냄

  • 현재 페이지(currentPage)와 동일하면 활성화(isActive=true)

  • 클릭 시 특정 페이지로 이동하는 handleClickPage(page) 호출

다음 페이지 버튼

      <PaginationButton key="nextPage" isDisAbled={currentPage === maxPage} handleClick={handleClickNextPage}>
        {nextPageChar}
      </PaginationButton>
  • 현재 페이지가 maxPage이면 비활성화

  • > 아이콘을 클릭하면 handleClickNextPage() 실행

느낀점

이번 페이지네이션 구현을 통해 UX 관점에서의 중요성을 다시 한번 깨닫게 되었습니다. 단순히 기능적인 요소가 아니라, 사용자 경험을 극대화할 수 있는 설계를 고민하는 과정이 매우 흥미로웠습니다. 앞으로도 더욱 직관적이고 효율적인 UI를 고민하며 개선해 나가고 싶습니다.

Last updated