검색 기능에 이어서 queryString으로 페이지 정보를 api로 주고 받아서 페이지네이션 기능을 구현하는 작업을 진행해보았다.
지난번에 페이지네이션 구현을 한번 해보았는데 너무 복잡하고 어렵게 구축이 되어서 좀 더 편한 방법을 찾아보려고 페이지네이션 라이브러리들을 많이 찾아보았는데 나만 그런건지 뭐가 문제인진 몰라도 계속해서 에러가 발생했다 ㅜㅜ;; (시간만 엄청 날림)
결국 우여곡절 끝에 그냥 기본 자바스크립트로 구현되었는데 그래도 페이지네이션 치고? 생각보다 많이 복잡한 로직은 아니게 구현되었다.
일단 페이지네이션을 위한 프론트엔드 코드이다.
currentPage라는 변수는 최초 1로 설정되어있으며 이따가 나오는 changePage라는 함수에 의해 값이 변한다.
원래 구성되어있었던 getProducts 함수는 파라미터로 페이지 정보가 들어가게 된다.
특이한 점은 최초 파라미터 세팅으로 page=1이라는 값이 들어가는데 이는 page라는 파라미터로 설정되는데 최초값은 1이라는 뜻이다.
이후 인자로 2, 3 이런 값이 들어오면 page에 2, 3과 같은 값이 들어오는 구조.
fetch요청 주소로 ?page=${page}가 추가되면서 백엔드로 페이지 값도 주소로 넣어서 요청하게 된다.
//상품 목록
const itemListContainer = document . getElementById ( "item-list-container" );
let currentPage = 1 ; // 현재 페이지를 추적
//getProducts의 파라미터는 page이며 기본값을 1로 한다는 함수 세팅
function getProducts ( page = 1 ) {
const searchInput = document . getElementById ( "search-input" ). value . trim ();
const query = encodeURIComponent ( searchInput ); // 검색어 인코딩
fetch ( ` ${ URI } /api/product?page= ${ page } &name= ${ query } ` )
. then (( response ) => response . json ())
. then (( data ) => {
console . log ( data );
itemListContainer . innerHTML = "" ;
// 상품 목록이 없을 때
if ( data . data . length === 0 ) {
itemListContainer . innerHTML = "<p>상품 목록이 없습니다.</p>" ;
return ;
}
// 상품 목록 추가
data . data . forEach (( item , i ) => {
const itemList = `
<div class="item-list" id="item-list ${ i } ">
<span> ${ item . sku } </span>
<span> ${ item . name } </span>
<span> ${ item . price } </span>
<img src=" ${ item . image } " alt="">
<span> ${ item . status } </span>
</div>
` ;
itemListContainer . insertAdjacentHTML ( "beforeend" , itemList );
});
// 페이지네이션 추가
createPagination ( data . totalPageNum );
})
. catch (( error ) => console . error ( "Error:" , error ));
}
//전체 상품 목록
getProducts ( page = 1 )
//페이지네이션 생성
function createPagination ( totalPageNum ) {
const paginationContainer = document . getElementById ( "pagination-container" );
paginationContainer . innerHTML = "" ; // 기존 페이지네이션 초기화
for ( let i = 1 ; i <= totalPageNum ; i ++ ) {
const pageButton = document . createElement ( "button" );
pageButton . textContent = i ;
//페이지 버튼 클릭 시
pageButton . addEventListener ( "click" , () => {
changePage ( i ); // changePage 호출
});
paginationContainer . appendChild ( pageButton );
}
}
//페이지 변경 시 목록과 url을 업데이트하는 함수
function changePage ( newPage ) {
currentPage = newPage ;
const searchQuery = document . getElementById ( "search-input" ). value . trim ();
updateURL ( searchQuery , currentPage ); // URL 업데이트
getProducts ( currentPage ); // 새 페이지의 상품 요청
}
다음으로는 페이지네이션 관련 함수가 두 개 나오게 되는데
하나는 페이지네이션 생성 관련 함수 (createPagination)
나머지 하나는 페이지네이션 버튼을 눌렀을 때 페이지 변경과 관련된 함수이다. (changePage)
createPagination 함수는 이름 그대로 페이지네이션을 만들어주는 함수인데
for문을 통해 버튼들을 만들고 그것을 눌렀을 때 changePage 함수를 호출하는 구조이다.
여기서 totalPageNum 파라미터는 getProducts에서 인자로 들어갈 것이며 그 값은 백엔드로부터 온다.
changePage 함수의 파라미터는 createPagination에서 클릭한 i값을 인자로 받아서 새로운 페이지 값을 currentPage로 넣어주는 함수이다. 그와 동시에 updateURL 함수와 getProducts를 갱신시켜서 해당 페이지에 맞는 정보로 업데이트 시켜준다.
//주소에 네임 및 페이지 정보 반영
function updateURL ( searchQuery , page ) {
const url = new URL ( window . location );
url . searchParams . set ( "name" , searchQuery ); // 검색어 설정
url . searchParams . set ( "page" , page ); // 페이지 번호 설정
window . history . pushState ({}, "" , url ); // URL 업데이트
}
//검색 버튼 눌렀을 때
document . getElementById ( "search-btn" ). addEventListener ( "click" , () => {
currentPage = 1 ; // 검색 시 페이지 리셋
const searchQuery = document . getElementById ( "search-input" ). value . trim ();
updateURL ( searchQuery , currentPage ); // URL 업데이트
getProducts ( currentPage ); // 검색어에 따라 상품 요청
});
//뒤로가기, 앞으로가기 시 주소 정보에 맞게 페이지 정보 반영
window . addEventListener ( "popstate" , ( event ) => {
const searchParams = new URLSearchParams ( location . search );
const searchQuery = searchParams . get ( "name" );
const page = searchParams . get ( "page" ) || 1 ; // 페이지 번호 가져오기, 없으면 기본값 1
// 검색어가 있으면 입력 필드에 설정
document . getElementById ( "search-input" ). value = searchQuery || "" ;
// 검색어가 있으면 검색 수행, 없으면 전체 목록 다시 불러오기
if ( searchQuery ) {
currentPage = parseInt ( page ); // 페이지 번호 설정
getProducts ( currentPage ); // 검색어에 따라 상품 요청
} else {
currentPage = parseInt ( page ); // 페이지 번호 설정
getProducts (); // 전체 목록 요청
}
});
updateURL 함수, 검색 버튼 눌렀을 때, popstate이벤트 등은 검색 기능 구현 때와 비슷하며 페이지네이션이 들어가면서 조금 더 내용이 보강되었다.
const PAGE_SIZE = 5 ;
productController . getProducts = async ( req , res ) => {
try {
//페이지, 검색 값이 있을 때
const { page , name } = req . query ;
//조건 (복수 고려)
const condition = name ? { name : { $regex : name , $options : "i" } } : {}
//선언
let query = Product . find ( condition );
let response = { status : "ok" };
//페이지 사이즈에 따라서 데이터 배분하기
if ( page ) {
query . skip (( page - 1 ) * PAGE_SIZE ). limit ( PAGE_SIZE );
//총 데이터 갯수
const totalItemNum = await Product . find ( condition ). countDocuments ();
//총 페이지 수
const totalPageNum = Math . ceil ( totalItemNum / PAGE_SIZE );
//리스폰스 객체에 totalPageNum 넣기 (page 유무로 넣을지 말지 정해짐)
response . totalPageNum = totalPageNum ;
}
//실행
const productList = await query . exec ();
//리스폰스 객체에 목록 data 넣어주기
response . data = productList ;
res . status ( 200 ). json ( response );
} catch ( error ) {
res . status ( 400 ). json ({ status : "fail" , message : error . message });
};
};
다음은 백엔드 코드이다.
const PAGE_SIZE = 5 ;
productController . getProducts = async ( req , res ) => {
try {
//페이지, 검색 값이 있을 때
const { page , name } = req . query ;
//조건 (복수 고려)
const condition = name ? { name : { $regex : name , $options : "i" } } : {}
//선언
let query = Product . find ( condition );
let response = { status : "ok" };
//페이지 사이즈에 따라서 데이터 배분하기
if ( page ) {
query . skip (( page - 1 ) * PAGE_SIZE ). limit ( PAGE_SIZE );
//총 데이터 갯수
const totalItemNum = await Product . find ( condition ). countDocuments ();
//총 페이지 수
const totalPageNum = Math . ceil ( totalItemNum / PAGE_SIZE );
//리스폰스 객체에 totalPageNum 넣기 (page 유무로 넣을지 말지 정해짐)
response . totalPageNum = totalPageNum ;
}
//실행
const productList = await query . exec ();
//리스폰스 객체에 목록 data 넣어주기
response . data = productList ;
res . status ( 200 ). json ( response );
} catch ( error ) {
res . status ( 400 ). json ({ status : "fail" , message : error . message });
};
};
PAGE_SIZE는 한 페이지에 들어올 수 있는 목록 수이다. 5로 설정되면 한페이지에 5개 목록이 들어온다는 뜻.
페이지 사이즈에 따라 데이터(목록)을 배분하는 부분이 독특한데 여기서 skip과 limit라는 기능이 쓰인다.
스킵은 괄호안의 값만큼 스킵한다는 뜻이고 리미트는 괄호안의 값보다 넘치면 제한하는 기능이다.
만약 페이지에 3이라는 값이 들어오고 PAGE_SIZE가 5로 설정되어있는 상태에서 목록 양이 14개라면
3페이지에 들어오는 정보는
스킵 안에 들어오는 값이 수식 계산에 따라 (3-1)*5 = 10개는 스킵하고 11~14번째 목록이 들어오는 구조이다.
한페이지에 들어오는 양은 5개로 리미트되는 것.
배분구조가 나오면 countDocuments()라는 메소드가 쓰이는데 이것 찾은 정보를 갯수값으로 반환해준다. 이 갯수를 totalItemNum이라는 값으로 넣어주고 이걸 다시 PAGE_SIZE로 나누어서 totalPageNum으로 넣어준다. (총 페이지 수)
그래서 최종적으로는 page값이 주소로 요청이 오면 totalPageNum을 응답해주게 된다.
이렇게 해서 페이지네이션 기능 완성!
검색 내용 없는 경우
검색 내용 있는 경우
두번째 해보는 것인데도 페이지네이션은 어려운 것 같다. 하다보면 점점 쉬워지지 않을까 ㅜㅜ