React 프로젝트를 진행하면서 페이징 처리한 것에 대한 정리.
구현 방법
리액트에서 페이지네이션을 구현하기 위해서는 라이브러리를 사용하는 방법과 직접 구현하는 방법이 있었다.
라이브러리는 알아보니까 material-ui 아니면 react-js-pagination을 많이 사용하는 것 같았다.
하지만 스프링 프로젝트 진행하면서 직접 구현했던 경험이 있으니 이번에도 직접 구현하는 방법을 택했다.
페이지네이션이 적용되는 페이지 구성
첫 리액트 프로젝트이기 때문에 작은 프로젝트 먼저 진행해서 게시글 리스트에서의 페이지네이션과 댓글에서의 페이지네이션 기능이 필요했다.
게시판에서는 전체 게시글을 대상으로 하는 페이지네이션과 검색 결과에 대한 페이지네이션을 처리해야 했고, 댓글은 해당 게시글의 전체 댓글에 대한 페이지네이션을 처리하면 됐다.
서버에서 응답받는 페이지네이션 데이터
서버에서는 Spring Data JPA로 처리하고 있었고 Pageable을 통해 처리하고 있다.
그래서 게시글 리스트인 content, 리스트가 비어있는지에 대한 여부인 empty, 첫 페이지인지에 대한 first, 마지막 페이지인지에 대한 last, 현재 조회중인 페이지 번호인 number, 총 페이지 개수인 totalPages를 전달 받도록 했다.
스프링 프로젝트로 진행할때도 이게 다 필요하진 않았고 totalPages 정도만 필요했었는데 React에서는 뭐가 필요할 지 몰라서 일단 필요해 보이는 데이터를 넘기도록 해놔서 좀 많이 받게 되었다..ㅎㅎ
그리고 결과적으로 이번에도 totalPages만 사용했다.
처리 방법
일단 페이지네이션 구조는 한번에 10개의 버튼이 출력되고 있고 가장 끝 버튼의 페이지 이후 페이지가 또 존재한다면 next가 출력되며 마찬가지로 가장 앞 버튼 페이지 앞쪽으로 페이지가 존재한다면 prev 버튼이 출력된다.
출력할 페이지 버튼의 범위를 계산하고, prev, next 버튼의 출력을 판단하기 위해 현재 페이지 번호와 totalPages 값을 통해 계산 한 뒤 객체 형태로 구성해 state에 저장하도록 했다.
이 처리과정을 리액트에서 모듈을 통해 처리하도록 했는데 서버에서 다~ 처리한 뒤에 보내주는 게 더 편할것 같다.
하지만 아직은 서버에서 처리하도록 수정하지 않은 상태고, 서버로 처리를 옮긴다고 해도 계산하는 부분만 달라지니 현재 구조대로 일단은 정리한다.
게시글 리스트 컴포넌트
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Paging form '../pagination/Paging';
import { createPagingObject } from '../../../modules/pagingModule';
import { axiosErrorHandling, boardAxios } from '../../../modules/customAxios';
function BoardPage() {
const [pageNum, setPageNum] = useState(1);
const [data, setData] = useState([]);
const [pagingData, setPagingData] = useState({
startPage: 0,
endPage: 0,
prev: false,
next: false,
activeNo: pageNum,
});
const navigate = useNavigate();
useEffect(() => {
getboardList(pageNum);
}, [pageNum]);
const getBoardList = async(pageNum) => {
await boardAxios.get(`?pageNum=${pageNum}`)
.then(res => {
const pagingObject= createPagingObject(pageNum, res.data.totalPages);
setData(res.data.content);
setPagingData({
startPage: pagingObject.startPage,
endPage: pagingObject.endPage,
prev: pagingObject.prev,
next: pagingObject.next,
activeNo: pageNum,
});
})
.catch(err => {
axiosErrorHandling(err);
});
}
//페이지 이동 클릭 이벤트
const handlePageNoBtnOnClick = (e) => {
setPageNum(e.target.textContent);
}
//이전 페이지 이동 클릭 이벤트
const handlePagePrevBtnOnClick = (e) => {
setPageNum(pagingData.startPage - 1);
}
//다음 페이지 이동 클릭 이벤트
const handlePageNextBtnOnClick = (e) => {
setPageNum(pagingData.endPage + 1);
}
return (
<div>
//list component
<Paging
pagingData={pagingData}
pageNumOnClick={handlePageNoBtnOnClick}
prevOnClick={handlePagePrevBtnOnClick}
nextOnClick={handlePageNextBtnOnClick}
/>
</div>
)
}
module. createPagingObject
export const createPagingObejct = (pageNum, totalPage) => {
let endPage = Number(Math.ceil(pageNum / 10.0) * 10);
const startPage = endPage - 9;
if(totalPages < endPage)
endPage = totalPages;
const prev = startPage > 1;
const next = endPage < totalPages;
return {
startPage: startPage,
endPage: endPage,
prev: prev,
next: next,
}
}
가장 먼저 테스트해보기 위해 작성했던 코드다.
게시판은 두개로 나눠져 있고, 댓글에서도 이 계산은 필요했기 때문에 모듈화를 했다.
startPage, endPage는 최종적인 페이지에 대한 값이 아닌 현재 출력시킬 범위의 시작과 끝을 의미한다.
그래서 endPage를 계산한 뒤 10개씩 출력할 것이기 때문에 startPage에서 9를 뺀 값을 담도록 했다.
그리고 만약 totalpages 보다 endPage가 큰 경우 존재하지 않는 페이지에 대한 버튼을 출력하지 않도록 하기 위해 endpage 값을 totalPages로 바꿔줬다.
prev는 11페이지에 접근했을 때부터 출력되어야 하고 1 ~ 10 페이지를 보는 동안에는 출력되면 안되기 때문에 startPage가 1보다 큰 경우에만 처리하도록 했으며, next 역시 endPage가 totalPages 보다 작은 경우에만 출력할 수 있도록 처리했다.
연산에 대한 결과를 객체로 담아 반환하도록 했고 호출한 컴포넌트에서는 반환받은 객체를 state에 담아 Page 컴포넌트에 전달한다.
Page 컴포넌트 코드는 여기 작성하지 않았는데 startPage ~ endPage 까지 버튼을 출력하도록 하고 prev, next는 boolean이니 그 값에 따라 버튼을 출력하도록 처리했다.
포스팅 하단에 최종적인 코드를 참고.
페이지네이션 버튼에 대한 클릭 이벤트는 pageNum state를 변경하는 것으로 재 렌더링을 수행하도록 해 처리했다.
그럼 작성해둔 이벤트 핸들러를 통해 버튼의 textContent를 알아내 pageNum state에 set을 수행하기 때문에 다음 데이터를 요청하고 렌더링을 수행해 정상적으로 출력해준다.
하지만 여기서 문제가 발생한다.
저렇게 처리하면 URL은 그대로 유지된다.
localhost:3000/board 라는 주소에서 게시글 리스트가 출력된다고 했을 때, 2번 페이지 버튼을 누르더라도 그대로 /board 를 유지하고 데이터만 변경된 상태로 렌더링 된다는 것이다.
물론 이것 자체가 문제가 되진 않는다. state 변경으로 인해 axios 요청도 제대로 처리하고 있고 리스트 데이터도 정상적으로 변경되기 때문이다.
하지만! 뒤로가기 버튼을 누르게 되면 이전 1번 페이지를 보여주는 것이 아닌 아예 다른 페이지를 보여준다.
예를들어 /image 주소의 이미지 게시판에 있다가 /board 로 넘어왔고 여기서 2페이지 버튼을 눌러 봤다고 가정하자.
그러다 1 페이지가 다시 보고 싶어 1페이지 버튼을 누르면 괜찮지만 뒤로가기 버튼을 누르는 경우 1페이지가 아닌 /image 로 이동하게 된다.
url 변경이 일어나지 않는 시점에서 보자마자 어? 했던 부분이었는데 역시나 의도하는 대로 동작하지 않았다.
방법을 찾아보니 뒤로가기를 감지할 수 있고 이전 데이터를 보여주기 위해 history를 통해 처리하도록 한다거나
슬쩍 본거긴 한데 storage에 저장해뒀다가 가져와서 보여준다거나 하는 방법들이 있었다.
이 문제에 대한 해결은 좀 더 원초적인 방법으로 해결하기로 했다.
url을 통해 페이지 이동을 하도록 하는 방법.
위 코드처럼 처리하는 것은 state를 업데이트해 재 렌더링을 발생시키고 그로 인한 변경을 하고 있지만,
그렇게 처리하지 않고 URL을 통해 페이지 번호를 넘겨 새로 렌더링 하는 방법을 택한 것이다.
일단 위 코드에서는 페이지네이션에 대해서만 처리가 되어 있지만, 검색기능도 있어야 하기 때문에 일단은 URL을 통한 처리로 먼저 해결하기로 했다.
뒤로가기 이슈 해결
페이지네이션 버튼 또는 검색으로 인한 이벤트 발생 시 쿼리 스트링을 통해 요청하고 컴포넌트에서는 그것을 받아 데이터를 요청한 뒤 렌더링 하도록 해야 한다.
그러기 위해 일단 App.js에서 쿼리 스트링을 통한 접근을 처리했다.
function App() {
return (
<BrowserRouter>
<Routes>
// 게시판 리스트.
<Route index element={<BoardPage />} />
//기본 리스트 페이지 이동
<Route path='?pageNum=:pageNum' element={<BoardPage />} />
//리스트 내 검색
<Route
path='?keyword=:keyword&searchType=:searchType'
element={<BoardPage />}
/>
//리스트 내 검색 데이터 페이지 이동
<Route
path='?keyword=:keyword&searchType=:searchType&pageNum=:pageNum'
element={<BoardPage />}
/>
//...
</Routes>
</BrowserRouter>
)
}
이것도 중첩 라우팅으로 해서 중첩해 개선하는 방법도 있었는데 욕심은 하나로 줄이고 싶어서 아직 수정하고 있지 않는 중...
이런식으로 여러개의 쿼리 스트링에 대해 게시글 리스트 페이지로 라우팅을 하도록 처리해뒀다.
게시글 리스트 컴포넌트
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SearchPaging from '../pagination/SearchPaging';
import { createPagingObject } from '../../../modules/pagingModule';
import { axiosErrorHandling, boardAxios } from '../../../modules/customAxios';
function BoardPage() {
const [params] = useSearchParams();
const pageNum = params.get('pageNum') == null ? 1 : params.get('pageNum');
const keyword = params.get('keyword');
const searchType = params.get('searchType');
const [data, setData] = useState([]);
const [pagingData, setPagingData] = useState({
startPage: 0,
endPage: 0,
prev: false,
next: false,
activeNo: pageNum,
});
const navigate = useNavigate();
useEffect(() => {
getBoardList(pageNum, keyword, searchType);
}, [pageNum, keyword, searchType]);
const getBoardList = async (pageNum, keyword, searchType) => {
await boardAxios.get(
`?keyword=${keyword}&searchType=${searchType}&pageNum=${pageNum}`
)
.then(res => {
const pagingObject = createPagingObject(pageNum, res.data.totalPages);
setData(res.data.content);
setPagingData({
startPage: pagingObject.startPage,
endPage: pagingObject.endPage,
prev: pagingObject.prev,
next: pagingObject.next,
activeNo: pageNum,
});
})
.catch(err => {
axiosErrorHandling(err);
}
}
return (
<div>
//list component
<SearchPaging
pagingData={pagingData}
keyword={keyword}
searchType={searchType}
/>
</div>
)
}
쿼리 스트링 값을 받기 위해 useSearchParams hook을 통해 받고 pageNum은 아무것도 없는 ' / ' 경로로 접근하더라도 1이라는 값이 필요하기 때문에 삼항연산자를 통해 처리한다.
keyword와 searchType의 경우 null이어도 문제가 되지 않기 때문에 그냥 두었고 axios 요청의 경우 null인 값은 무시하기 때문에 문제가 되지 않았다.
pagingObject의 처리는 수정없이 이전과 동일하게 처리하고 state에 대한 set 역시 동일하게 두었다.
페이징 컴포넌트의 경우 조금 수정이 있었는데 Paging 컴포넌트와 Search 컴포넌트를 생성하고 이 두 컴포넌트를 갖고 있는 SearchPaging 컴포넌트를 통해 처리하도록 했다.
이유는 두개의 게시판에서 동일하게 검색 및 페이징 기능을 갖고 있어야 하는데 각 최상위 컴포넌트에서 이벤트에 대한 처리를 담당하지 않도록 하기 위해서였다.
그 이벤트에 대한 처리는 SearchPaging 컴포넌트에서 담당하도록 해서 분리했다.
module. pagingModule
export function handlePageNumBtn (e, navigate, keyword, searchType) {
const clickNo = e.target.textContent;
paginationNavigate(clickNo, keyword, searchType, navigate);
}
export function handlePrevBtn (startPage, navigate, keyword, searchType) {
const prevNumber = startPage - 1;
paginationNavigate(prevNumber, keyword, searchType, navigate);
}
export function handleNextBtn (endPage, navigate, keyword, searchType) {
const nextNumber = endPage + 1;
paginationNavigate(nextNumber, keyword, searchType, navigate);
}
export function handleSearchBtnOnClick (navigate, keyword, searchType) {
searchNavigate(navigate, keyword, searchType);
}
const searchNavigate = (navigate, keyword, searchType) => {
navigate(`?keyword=${keyword}&searchType=${searchType}`);
}
const paginationNavigate = (clickNo, keyword, searchType, navigate) => {
if(keyword == null){
navigate(`?pageNum=${clickNo}`);
}else {
navigate(`?keyword=${keyword}&searchType=${searchType}&pageNum=${clickNo}`);
}
}
SearchPaging 컴포넌트
import React from 'react';
//...
import {
handlePageNumBtn
, handlePrevBtn
, handleNextBtn
} from '../../../modules/pagingModule';
function SearchPaging(props) {
const { pagingData, keyword, searchType } = props;
const navigate = useNavigate();
const handlePageNoBtnOnClick = (e) => {
handlePageNumBtn(e, navigate, keyword, searchType);
}
const handlePagePrevBtnOnClick = (e) => {
handlePrevBtn(pagingData.startPage, navigate, keyword, searchType);
}
const handlePageNextBtnOnClick = (e) => {
handleNextBtn(pagingData.endPage, navigate, keyword, searchType);
}
return (
<>
<Search
keyword={keyword}
searchType={searchType}
/>
<Paging
pagingData={pagingData}
pageNumOnClick={handlePageNoBtnOnClick}
prevOnClick={handlePagePrevBtnOnClick}
nextOnClick={handlePageNextBtnOnClick}
/>
</>
)
}
Paging 컴포넌트
import React from 'react';
import styled from 'styled-components';
import PagingButton from './PagingButton';
const PagingliWrapper = styled.li`
list-style: none;
float: left;
padding: 5px;
`
function Paging(props) {
const { pagingData, pageNumOnClick, prevOnClick, nextOnClick } = props;
let prevElem = null;
let nextElem = null;
const pagingNumberArr = [];
if(pagingData.endPage !== 1) {
for(let i = pagingData.startPage; i <= pagingData.endPage; i++) {
let pagingClassName = 'pagingNumber';
if(i === Number(pagingData.activeNo))
pagingClassName = pagingClassName + ' active';
const body = {
pageNum: i,
className: pagingClassName,
}
pagingNumberArr.push(body);
}
}
if(pagingData.prev)
prevElem = <PagingliWrapper>
<PagingButton
btnText={'prev'}
className={'pagingPrev'}
onClick={prevOnClick}
/>
</PagingliWrapper>;
if(pagingData.next)
nextElem = <PagingliWrapper>
<PagingButton
btnText={'next'}
className={'pagingNext'}
onClick={nextOnClick}
/>
</PagingliWrapper>;
return(
<div className='paging'>
<ul>
{prevElem}
{pagingNumberArr.map((pagingNum, index) => {
return (
<PagingNumber
key={index}
pagingNumberData={pagingNum}
btnOnClick={pageNumOnClick}
/>
)
})}
{nextElem}
</ul>
</div>
)
}
function PagingNumber(props) {
const { pagingNumberData, btnOnClick } = props;
return (
<PagingliWrapper>
<PagingButton
btnText={pagingNumberData.pageNum}
className={pagingNumberData.className}
onClick={btnOnClick}
/>
</PagingliWrapper>
)
}
위 코드는 현재 구현된 코드다.
SearchPaging에서 페이지네이션 버튼 클릭 이벤트에 대한 핸들링을 하고 있고 각 처리는 모듈화를 통해 pagingModule.js에서 처리하고 있다.
페이지 이동을 위해 useNavigate를 같이 전달해 사용하도록 처리했다.
이때 좀 신기했던게 paginationNavigate나 searchNavigate에서 볼 수 있듯이 앞에 게시판의 기본 url 이 필요하지 않다.
이게 어떻게 처리되는지 좀 알아보고자 했는데 아직까지 해답을 찾지 못했다.
그냥 개인적인 생각이지만 아마도 쿼리스트링만 작성하면 현재 url 뒤에 붙여서 처리하기 때문이지 않나 라는 생각을 한다.
굳이 페이지네이션 이벤트에 대한 처리를 모듈화한 이유는 각 게시판에서만 처리하는 경우였다면 SearchPage 컴포넌트에서 처리했겠지만 댓글의 경우 Paging 컴포넌트만 필요하기 때문에 SearchPage 컴포넌트를 하위 컴포넌트로 두지 않는다.
그래서 댓글의 페이지네이션 이벤트 처리를 위해 모듈화를 하게 되었다.
Paging 컴포넌트에서는 조건에 맞는 버튼을 출력하기 위해 각 엘리먼트에 대해 처리한 뒤 렌더링을 수행할 수 있도록 처리했다.
'Front > React' 카테고리의 다른 글
React 날짜 포맷 (0) | 2024.04.14 |
---|---|
React 이미지 처리 (0) | 2024.04.14 |
React 이미지 다중 업로드 및 수정, 요청 전송 (0) | 2024.04.13 |
React에서 state 배열 관리 및 반복문에서의 state (0) | 2024.04.13 |
React 페이지 이동 (1) | 2024.04.12 |