DogKaeBi

[Next.js 블로그] 페이지네이션

Pagination은 페이지 번호의 navigation이다. 번호 생성, 이동 기능을 고려해서 만들어야 한다.

[Next.js 블로그] 페이지네이션

시작하기 전에...
내용이 많이 길다.

UI, function을 만들고
부모에서 데이터도 받아야 한고,
데이터 컨트롤러도 수정했다.


기존 진행된 내용

카드 리스트 이전 참고 내용

[카드 리스트 기본틀]
[카드 리스트 grid]
[한자 카드]
[블로그 카드]
[블로그 카드 이미지]
[페이지별 다른 카드 리스트]
[카드 수량 제한/컨트롤러]

/cantonese 한자 페이지
/cantonese/word 단어 페이지
/blog 블로그 페이지

에서 CardList 를 사용했다.
각 페이지에서 타입과 데이터를 전달해서
CardList에서 grid 디자인을 조절하고,
CnCardBlogCard를 선택했다.



Pagination 계획

Pagination

Pagination은 이름처럼
페이지 번호 navigation이다.

페이지 번호를 클릭해 해당 페이지로 이동하거나
앞/뒤 버튼으로 이전/다음 페이지로 이동한다.


계획

따로 컴포넌트를 만들 생각이다.
사용되는 곳은 CardList 이지만...
만들면서 느낀 것은
각 페이지에서 데이터를 받기 때문에
각 페이지에서 사용하는 것이 적합했다.

  • 다음/이전 버튼
  • 현재 페이지 강조
  • 현재 페이지 앞뒤 페이지 표시
  • 앞 페이지와 첫 페이지 사이 생략표시
  • 뒤 페이지와 마지막 페이지 사이 생략표시
  • 파라미터 커리로 페이지를 사용

UI을 예시로 그려보면

◀ 1 … 4 5 6 … 10 ▶



Pagination 준비

컴포넌트 생성

[컴포넌트 편]참고
전에 만들었던 컴포넌트 폴더에 Pagination.js을 추가했다.

// .\app\components\Pagination.js

const Pagination = () => {
  return <>It's Pagination</>;
};

필요한 데이터

페이지네이션에서 필요한 변수가 많다.

  • 현재 페이지
  • 총 페이지 수량
  • 현재 태그 ( SubNav 내용 [서브nav 편]참고 )

컴포넌트 자체에서 데이터를 확인할 수 있지만,
함수를 사용하고 변수 더 선언하는 것이
효과적이지 않을 것 같았다. (개인 생각)

react.js의 useSearchParams을 사용하면,
client로 만들어야 해서 좀 찜찜하기도 했다.

결론은..
부모로 부터 데이터를 받았다.

// .\app\components\Pagination.js

const Pagination = (props) => {
  const currentPage = props.page;
  const pageQty = props.maxPage;
  const tag = props.tag;

  return (
    <>
      It's Pagination {currentPage} / {pageQty} / {tag}
    </>
  );
};

아직 부모에서 데이터를 전달을 하지 않아서 오류가 발생한다.


getCardData 수정

[카드 수량 제한/컨트롤러]


현재 페이지와 카테고리(태그)도 이미 있는 데이터이다.

문제는 페이지 수량이다.
getCardData는 전체가 아닌
maxCardInPage의 수량 만큼만
데이터를 받아온다.

getCardData.js에서 데이터를 제어하기 때문에
getCardData의 return 값을
객체로 변경하고
allLength로 전체 길이
sortedData으로 데이터를 반환했다.

// .\app\controller\getCardData.js - getCardData

export function getCardData(category, pageNum, maxCardInPage) {
  const data = getCategoryData(category);
  const dataLength = data.length;
  const isFullCard = dataLength >= pageNum * maxCardInPage;
  const cardLength = isFullCard ? maxCardInPage : dataLength % maxCardInPage;
  const startCardNum = (pageNum - 1) * maxCardInPage;
  const endCardNum = startCardNum + cardLength;

  return {
    allLength: dataLength,
    sortedData: data.slice(startCardNum, endCardNum),
  };
}

컴포넌트 적용

출력된 내용을 확인하기 위해
컴포넌트를 Blog페이지에 사용하고,

변경된 getCardData도 적용시켰다.

< -- 참고 ----
/cantonese/page.js 한자 페이지
/cantonese/word/page.js 단어 페이지
/blog/page.js 블로그 페이지
에서 사용된다. 한자와 단어는 우선 보류
---- 참고 -->
// .\app\blog\page.js - 추가 내용 외 생략
...
import Pagination from "@components/Pagination";

export default function Blog(props) {
  const tag = props.searchParams.tag; // SubNav 편
  const page = props.searchParams.page ?? 1; // controller 편
  const maxCardInPage = 6; // controller 편
  const data = getCardData(tag, page, maxCardInPage);
  const blogData = data.sortedData;
  const allLength = data.allLength;
  const maxPage = Math.ceil(allLength / maxCardInPage);

  return (
    <>
      ...
      <Pagination page={page} tag={tag} maxPage={maxPage}/>
    </>
  )
}

전체 길이 data.allLength을 받아서
최대 수량 maxCardInPage으로 나누고
Math.ceil을 사용해 올림처리를 했다.



Pagination 기능

만들기 전 계획을 보면,

  • 7페이지 이하는 모든 번호가 노출된다.
  • 첫 페이지가 아니면 prev 버튼이 있다.
  • 끝 페이지가 아니면 next 버튼이 있다.
  • 현재 페이지는 강조되고 click event가 없다.

그래서 생각한 방법은
페이지 번호 리스트를 만들고
리스트를 출력하는 방식이다.


setPageNums 페이지 배열

우선 함수 setPageNums를 만들었다.

// .\app\components\Pagination.js - setPageNums 필요 외 생략

const Pagination = (props) => {
  const tag = props.tag;
  const page = props.page;
  const pageQty = props.maxPage;
  const pageNums = setPageNums(page, pageQty);

  function setPageNums(currentPage, lastPage){
    if (lastPage < 7) {
      let res = [];
      for (let i = 1, i <= lastPage ; i++) {
        res.push(i);
      }
      return res;
    } else {
      if (currentPage == 1) return [1, 2, -1, lastPage];
      if (currentPage == 2) return [1, 2, 3, -1, lastPage];
      if (currentPage == 3) return [1, 2, 3, 4, -1, lastPage];
      if (currentPage == lastPage) return [1, -1, lastPage - 1, lastPage];
      if (currentPage == lastPage - 1) return [1, -1, lastPage - 2, lastPage - 1, lastPage];
      if (currentPage == lastPage - 2) return [1, -1, lastPage - 3, lastPage - 2, lastPage - 1, lastPage];
      return [1, -1, currentPage - 1, currentPage, currentPage + 1, -2, lastPage];
    }
  }
  ...
};

오류 발생. 밑에서 설명
함수를 UI에 적용할 때 오류가 발생한다.
1 타입 오류
2 요소 중복 렌더링 오류


setPageNums를 위에서부터 보면

  • 현재 페이지와 마지막 페이지를 받는다.
  • 7 페이지 이하이면 for반복으로 2부터 시작하는 배열을 return

페이지 길이가 10으로 가정하면

  • 현 페이지가 1 : 1 2 … 10
  • 현 페이지가 2 : 1 2 3 … 10
  • 현 페이지가 3 : 1 2 3 4 … 10
  • 현 페이지가 마지막 페이지 : 1 … 9 10
  • 현 페이지가 마지막 전 페이지 : 1 … 8 9 10
  • 현 페이지가 마지막 전전 페이지 : 1 … 7 8 9 10
  • 다른 경우 (5) : 1 … 4 5 6 … 10

--- 참고 ---
7개 요소 이상인 이유 : ◀ 1 … 4 5 6 … 10 ▶
1의 UI : 1 2 … 10 ▶
2의 UI : ◀ 1 2 3 … 10 ▶
3의 UI : ◀ 1 2 3 4 … 10 ▶
끝의 UI : ◀ 1 … 9 10
끝-1의 UI : ◀ 1 … 8 9 10 ▶
끝-2의 UI : ◀ 1 … 7 8 9 10 ▶
--- 참고 ---


setParams 링크 생성

  • 번호를 클릭하면 해당 page 이동
  • 앞/뒤 버튼 클릭하면 앞/뒤 페이지 이동

이상하게 SSL을 고집했다...

우선 Link 컴포넌트를 사용하고
href으로 객체를 전달할 때,
key값을 query로 객체를 전달하면 query 파라미터를 사용할 수 있다.
key값 pathname을 전달하지 않으면 같은 페이지의 query만 변경된다.
Next.js 공식문

예시: <Link href={query: {page=1}}> 1 <Link/>


tag도 사용해야 한다.
tag가 없으면 pagination을 사용하면 전체 tag 페이지로 이동한다. 그래서 setParams 함수는

// .\app\components\Pagination.js - setParams 필요 외 생략

const Pagination = (props) => {
  const tag = props.tag;
  const pageQty = props.maxPage;
  ...

  function setParams(num){
    if (num < 1 || num > pageQty) return;
    if (tag == "" || tag == null) {
      return { page : num };
    } else {
      return { tag : tag, page: num };
    }
  }
}
  • 클릭된 num을 받는다
  • 클릭된 num이 1보다 작거나 끝보다 크면 무반응
  • tag가 없으면 페이지 객체 return
  • tag이 있으면 태그 및 페이지 객체 return


Pagination UI

// .\app\components\Pagination.js - return 외 생략
import Link from "next/link";
...
const pageNums = setPageNums(page, pageQty);
...
return (
  <div className="flex justify-center items-center">
    {page != 1 ? <Link href={{query: setParams(page - 1)}}>◀<Link/> : null}
    {pageNums.map((num) => {
      return num == -1 || num == -2 ? (
        <span key={("btn", num)}>...</span>
      ) : page == num ? (
        <span key={("btn", num)}>{num}</span>
      ) : (
        <Link key={("btn", num)} href={{ query: setParams(num) }}>
          {num}
        </Link>
      );
    })}
    {pageQty == 0 || pageQty == 1 || pageQty == page ? null
      : <Link href={{query: setParams(page + 1)}}>▶<Link/>}
  </div>
)

가로 배치 중간 정렬

ul으로 만들려고 했지만
자녀가 Link 이어서 div로 만들었다.

tailwindcss로
flex를 사용해서 자녀를 가로로 배치하고 중간정렬을 했다.

<div className="flex justify-center items-center"></div>

Prev 버튼

{page != 1 ? <Link href={{query: setParams(page - 1)}}>◀<Link/> : null}
  • 현재 페이지가 1이 아니면 버튼 생성
  • setParams으로 현재 page-1 의 링크 생성

페이지 버튼

{
  pageNums.map((num) => {
    return num == -1 || num == -2 ? (
      <span key={("btn", num)}>...</span>
    ) : page == num ? (
      <span key={("btn", num)}>{page}</span>
    ) : (
      <Link key={("btn", num)} href={{ query: setParams(num) }}>
        {num}
      </Link>
    );
  });
}
  • pageNumsmap()반복
  • -1, -2<span>...</span> 출력
  • 현재 page와 같은 숫자는 <span>으로 출력
  • 다른 숫자는 <Link> 출력. setParams으로 경로 만듦

현재 페이지는 <span>을 출력했다.
위의 예시는 아직 디자인이 없지만
실제로는 <span>의 클라스로 text-green-500으로 강조했다.


Next 버튼

{pageQty == 0 || pageQty == 1 || pageQty == page ? null
  : <Link href={{query: setParams(page + 1)}}>▶<Link/>}
  • 총 페이지 수 0, 1 혹 현재 page가 마지막 페이지는 null
  • 아니면 버튼 생성. 링크 setParams(page + 1)


에러 발생

오류가 발생했다.

  1. 번호가 이상하게 나온다.
  2. 타입 오류
  3. 생략 기호 중복

타입 오류

1번과 2번은 같은 이유로 발생하는 문제이다.

const page = props.page;
const pageQty = props.maxPage;

처음에 props로 현재 페이지와 마지막 페이지를 받았다.
하지만 props로 받은 내용은 string으로 되어 있었다.

형변환을 해서 간단하게 해결했다.

const page = Number(props.page);
const pageQty = Number(props.maxPage);

요소 중복 오류

setPageNums에서
[1, -1, page-1, page, page+1, -1, lastPage] 반환할 때,

생략 중복되는 현상이 있었다.
React를 사용하면 요소가 중복 렌더링되는 경우를 많이 보게 된다.
React의 기능으로만 보면
렌더링이되는 경우를 고려해서 useEffect를 적절히 사용해야 했다.

Next.js에서는 React와 좀 다르게
요소가 중복으로 만들어지는 경우는
대부분 key값이 중복된 경우이다.

<span key={("btn", num)}>...</span>
key값을 반복 요소의 num을 사용했다.
앞뒤의 생략이 같은 -1이어서 key도 같았다.
그래서
앞의 생략은 -1을 그대로 사용하고
뒤의 생략은 -2로 변경하고
조건문도 -2를 추가해서 문제를 해결했다.

다른 방법으로는
배열에서 -1을 삭제하고
UI 부분에서
현재 페이지 전 번호,
현재 페이지 후 번호 출력에서
span을 포함해도 된다.

<>
  <span>...</span>
  <Link>{num}</Link>
</>
<>
  <Link>{num}</Link>
  <span>...</span>
</>

위의 코드를 응용해서
더 많은 방식을 생각할 수 있을 것이다.



결론

위의 방식으로...
드디어 pagination을 완료했다.

// .\app\components\Pagination.js

const Pagination = (props) => {
  const tag = props.tag;
  const pageQty = Number(props.maxPages);
  const page = Number(props.page);
  const pageNums = setPageNums(page, pageQty);

  function setPageNums(currentPage, lastPage){
    if (lastPage < 7) {
      let res = [];
      for (let i = 1, i <= lastPage ; i++) {
        res.push(i);
      }
      return res;
    } else {
      const prevPage = currentPage - 1;
      const nextPage = currentPage + 1;
      if (currentPage == 1) return [1, 2, -1, lastPage];
      if (currentPage == 2) return [1, 2, 3, -1, lastPage];
      if (currentPage == 3) return [1, 2, 3, 4, -1, lastPage];
      if (currentPage == lastPage) return [1, -1, prevPage, lastPage];
      if (currentPage == lastPage - 1) return [1, -1, prevPage, currentPage lastPage];
      if (currentPage == lastPage - 2) return [1, -1, prevPage, currentPage, nextPage, lastPage];
      return [1, -1, prevPage, currentPage, nextPage, -2, lastPage];
    }
  }

  function setParams(num){
    if (num < 1 || num > pageQty) return;
    if (tag == "" || tag == null) {
      return { page : num };
    } else {
      return { tag : tag, page: num };
    }
  }

  return (
    <div className="flex justify-center items-center">
      {page != 1 ? <Link href={{query: setParams(page - 1)}}>◀<Link/> : null}
      {pageNums.map((num) => {
        return num == -1 || num == -2 ? (
          <span key={("btn", num)}>...</span>
        ) : page == num ? (
          <span key={("btn", num)}>{num}</span>
        ) : (
          <Link key={("btn", num)} href={{ query: setParams(num) }}>
            {num}
          </Link>
        );
      })}
      {pageQty == 0 || pageQty == 1 || pageQty == page ? null
        : <Link href={{query: setParams(page + 1)}}>▶<Link/>}
    </div>
  )
}

한 페이지에 만들어서 코드가 길다.
setPageNums를 별도 파일로 만들면 조금 보기가 좋을 것 같다.

결론은...

Pagination을 만들기 위해
페이지 수량pageQty이 필요했다.
총 수량은 끝 페이지 번호이기도 하다.

현재 페이지page을 사용해서
Pagination의 형태를 결정했다.
[1 … 3 4 5 … 7 ] 은 기본이지만
[1 2 3 4 5 6] ,
[1 2 … 7]
같은 상황을 고려해서 배열을 만드는 setPageNums 함수를 만들었다.

UI는 배열을 반복해서 만들었다.
css로 현재 페이지를 강조하고
조건에 따라 spanLink을 사용했다.

Link의 href는 setParams을 사용해 만들었다.
무효 번호는 무반응.
유효 번호는 query에 들어갈 객체를 return한다.

Next 버튼이 없는 경우는 3가지이다.
1- 총 페이지 없음
2- 총 페이지 1개
3- 현재가 마지막 페이지


이렇게 Pagination을 완성했다.