페이지네이션이란?
데이터의 양이 많을 때 하나로 길게 보여줄 데이터들을
여러 조각으로 나누어 보여주는 것을 의미한다.
페이지네이션을 사용했을 때의 장점은
한 번에 필요한 데이터만 보여주기 때문에 서버 성능이 향상될 수 있고,
더 깔끔한 화면을 제공하여 사용자 경험을 개선할 수 있다.
이런 페이지네이션 구현 방식에는
번호를 선택하는 페이지네이션이나 무한스크롤 방식 등이 있는데
이번 포스팅에는 버튼식 페이지네이션에 대해 글을 작성해보려고 한다.
기존 우리 프로젝트에서는 주요 목록인 상품과 게시판에 페이지네이션이 필요한 상황이었다.
최종 프로젝트로 들어가면서, 상품에는 페이지네이션 / 게시판에는 무한스크롤을 적용하기로 결정.
🍀 페이지네이션
- 한 페이지에 보여줄 데이터는 9개씩
- 페이지 번호를 보내면 DB에서 요청하는 페이지의 데이터를 잘라서 보내준다.
- 전체 데이터가 몇 페이지인지 DB에서 받아 페이지네이션 적용시킨다.
- 사용자가 선택하는 location(바다/민물) / category (낚시터, 낚시배, 낚시카페, 수상)이 있다.
- 데이터를 정렬하는 정렬 기준이 있다.
- 사용자는 상품명을 검색할 수 있다. (현재는 필터 기능 도입하면서 일반 상품 조회시에는 검색 사라짐)
// Model (sql문)
// 전체출력 통합 >> 정렬기준 + 검색어
private final String PRODUCT_SELECTALL =
"SELECT PRODUCT_NUM, PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_ADDRESS, " +
"PRODUCT_LOCATION, PRODUCT_CATEGORY, RATING, PAYMENT_COUNT, " +
"WISHLIST_COUNT, FILE_DIR " +
"FROM ( " +
" SELECT PRODUCT_NUM, PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_ADDRESS, " +
" PRODUCT_LOCATION, PRODUCT_CATEGORY, RATING, PAYMENT_COUNT, " +
" WISHLIST_COUNT, FILE_DIR, " +
" ROW_NUMBER() OVER ( " +
" ORDER BY CASE " +
" WHEN ? = 'newest' THEN PRODUCT_NUM " +
" WHEN ? = 'rating' THEN COALESCE(RATING, -1) " +
" WHEN ? = 'wish' THEN COALESCE(WISHLIST_COUNT, -1) " +
" WHEN ? = 'payment' THEN COALESCE(PAYMENT_COUNT, -1) " +
" ELSE PRODUCT_NUM " +
" END DESC " +
" ) AS ROW_NUM " +
" FROM PRODUCT_INFO_VIEW " +
" WHERE PRODUCT_NAME LIKE CONCAT('%', COALESCE(?, ''), '%') " +
" AND (PRODUCT_LOCATION = COALESCE(?, PRODUCT_LOCATION)) " +
" AND (PRODUCT_CATEGORY = COALESCE(?, PRODUCT_CATEGORY)) " +
") AS subquery " +
"WHERE ROW_NUM BETWEEN (COALESCE(?, 1) - 1) * 9 + 1 AND COALESCE(?, 1) * 9";
사용자가 선택하는 모든 기준이 한 번에 조회되어야 하는데,
어떤 기준이 들어올지 모르기 때문에 SQL문을 하나로 통합하였다.
정렬 기준에 따라 ROW_NUMBER() 함수를 사용해 순번을 주고,
BETWEEN AND 구문을 사용하여 쿼리 파라미터 자리에 사용자가 선택한 페이지 번호를 입력해
원하는 데이터만 잘라서 줄 수 있도록 작성되었다.
MySQL에서는 limit과 offset을 사용하는 방식으로도 하는데,
MySQL 8.0부터는 BETWEEN 사용 가능하다고 하여 우리 팀은 이 방식을 유지하였다.
// boardList..jsp (바디 안쪽 일부)
<!-- 상품 검색창 섹션 시작 -->
<div class="search-container">
<div class="search-box">
<!-- 정렬 기준 버튼 -->
<div class="sort-options">
<label><input type="radio" name="searchOption" value="newest" checked> 최신순 </label>
<label><input type="radio" name="searchOption" value="rating"> 별점순 </label>
<label><input type="radio" name="searchOption" value="wish"> 찜 많은 순 </label>
<label><input type="radio" name="searchOption" value="payment"> 결제 많은 순 </label>
</div>
</div>
</div>
<br>
<!-- 상품 검색창 섹션 시작 -->
<!-- 상품 목록 섹션 시작 -->
<section class="blog-section blog-page spad">
<div class="container">
<div class="row">
<!-- 현재 페이지 설정 -->
<c:set var="currentPage"
value="${param.currentPage != null ? param.currentPage : 1}" />
<!-- 검색한 상품이 없는 경우 -->
<c:if test="${empty productList}">
<!-- productList가 비어있는지 검사 -->
<div class="col-md-4">
<!-- 3열 그리드 -->
<p>검색 결과가 없습니다.</p>
<!-- 검색 결과가 없을 때 메시지 -->
</div>
</c:if>
<!-- c:forEach를 사용하여 상품 항목 반복 시작 -->
<div class="row" id="productList">
<c:forEach var="product" items="${productList}">
<!-- productList를 반복 -->
<div class="col-md-4">
<!-- 3열 그리드 -->
<c:if test="${not empty product.product_file_dir}">
<img alt="상품사진입니다" class="blog-item set-bg"
src="${product.product_file_dir}">
</c:if>
<c:if test="${empty product.product_file_dir}">
<img alt="상품사진입니다" class="blog-item set-bg"
src="img/board/boardBasic.png">
</c:if>
<div class="bi-text">
<!-- 상품 카테고리 -->
<span class="b-tag">${product.product_location}</span>🌊<span
class="b-tag">${product.product_category}</span>
<!-- 상품 별점 평균 -->
<span>⭐${product.product_avg_rating}⭐</span>
<!-- 상품명 및 링크 -->
<!-- 상품의 PK값과 함께 이동 -->
<h4>
<a href="productDetail.do?product_num=${product.product_num}">${product.product_name}</a>
</h4>
<span class="b-tag">상품가격 : ${product.product_price}₩</span> <br>
<br>
<br>
</div>
</div>
</c:forEach>
</div>
</div>
</div>
</section>
<!-- 상품 목록 섹션 종료 -->
<!-- 페이지네이션 섹션 시작 -->
<div class="col-lg-12">
<div class="pagination">
<c:if test="${currentPage > 1}">
<a href="#" class="page-link" data-page="${currentPage - 1}">이전</a>
</c:if>
<c:forEach var="i" begin="1" end="${product_page_count}">
<c:if test="${i == currentPage}">
<strong>${i}</strong>
</c:if>
<c:if test="${i != currentPage}">
<a href="#" class="page-link" data-page="${i}">${i}</a>
</c:if>
</c:forEach>
<c:if test="${currentPage < product_page_count}">
<a href="#" class="page-link" data-page="${currentPage + 1}">다음</a>
</c:if>
</div>
</div>
<!-- 페이지네이션 섹션 종료 -->
정렬 기준은 라디오 버튼으로 변경되었고, (기존 드롭다운 선택 + 검색창)
기본값은 "최신순"이다.
페이지네이션 섹션에는 현재 페이지와 총 페이지 수를 받아 보여준다.
스크립트 부분은 외부js를 사용하여 연결해 주었다.
// boardList.js
$(document).ready(function() {
console.log("문서 로드 완료");
var searchKeyword = $('input[name="product_searchKeyword"]').val(); // 검색어
var productLocation = $('#product_location').val(); // 바다, 민물
var productCategory = $('#product_category').val(); // 낚시배, 낚시터, 수상, 낚시카페
console.log("searchKeyword 문서 로드 후 ["+searchKeyword+"]");
console.log("productLocation 문서 로드 후 ["+productLocation+"]");
console.log("productCategory 문서 로드 후 ["+productCategory+"]");
// 정렬 옵션 라디오 버튼 클릭 이벤트
$('input[type="radio"][name="searchOption"]').on('change', function() {
var searchOption = $(this).val(); // 선택된 정렬 기준 가져오기
loadProducts(1, searchOption); // 첫 페이지에서 정렬 적용
});
// 페이지네이션 클릭 이벤트
$('.pagination').on('click', '.page-link', function() {
console.log("페이지네이션 클릭 이벤트");
var currentPage = $(this).data('page');
var searchOption = $('input[type="radio"][name="searchOption"]:checked').val(); // 선택된 정렬 옵션
console.log("currentPage ["+currentPage+"]");
loadProducts(currentPage, searchOption); // 클릭한 페이지 번호로 로드
});
// 제품 로드 함수
function loadProducts(currentPage, searchOption) {
// 페이지가 로드될 때마다 필요한 값 업데이트
var searchKeyword = $('input[name="product_searchKeyword"]').val();
var productLocation = $('#product_location').val();
var productCategory = $('#product_category').val();
console.log("searchKeyword ["+searchKeyword+"]");
console.log("searchOption ["+searchOption+"]");
console.log("productLocation ["+productLocation+"]");
console.log("productCategory ["+productCategory+"]");
// AJAX 요청
$.ajax({
url: 'productList.do'
type: 'GET',
data: {
"currentPage": currentPage,
"product_searchKeyword": searchKeyword,
"product_search_criteria": searchOption,
"product_location": productLocation,
"product_category": productCategory
},
dataType: 'json', // 응답 데이터 유형을 JSON으로 설정
success: function(data) {
console.log("ajax요청 성공 반환");
console.log("ajax요청 성공 currentPage : " +currentPage+ "]");
// responseMap에서 productList 추출
const productList = data.productList;
let productHTML = ''; // 제품 정보를 담을 변수
// 기존 상품 목록을 지우고 새로운 목록으로 대체
$('.blog-section .row').empty();
console.log("productList ["+productList+"]")
if (!productList || productList.length === 0) {
console.log("더 이상 불러올 게시글이 없습니다.");
productHTML +='<p>데이터 없음</p>'; // 데이터가 없을 경우 메시지 표시
updatePagination(1, 1); // 페이지네이션 초기화
}
else{
// 반복문을 돌려 json 타입의 데이터를 html 요소에 반영한다.
productList.forEach((product, i) => {
console.log("forEach 시작 i:" + i);
productHTML += ' <div class="col-md-4">' +
'<img alt="상품사진" class="blog-item set-bg" src="' + (product.product_file_dir ? product.product_file_dir : 'img/board/boardBasic.png') + '">' +
'<div class="bi-text">' +
'<span class="b-tag">' + product.product_location + '</span>🌊' +
'<span class="b-tag">' + product.product_category + '</span>' +
'<span>⭐' + product.product_avg_rating + '⭐</span>' +
'<h4><a href="productDetail.do?product_num=' + product.product_num + '">' + product.product_name + '</a></h4>' +
'<span class="b-tag">상품가격 : ' + product.product_price + '₩</span>' +
'</div></div>';
});
// 페이지네이션 업데이트 함수 호출
updatePagination(currentPage, data.product_page_count);
}
$('.blog-section .row').append(productHTML);
}
});
}
// 페이지 버튼 상태 업데이트 함수
function updatePagination(currentPage,totalPages) {
console.log("페이지네이션 버튼 로그 currentPage ["+currentPage+"]")
console.log("페이지네이션 버튼 로그 totalPages ["+totalPages+"]")
var paginationHtml = '';
if (currentPage > 1) {
paginationHtml += '<a href="#" class="page-link" data-page="' + (currentPage - 1) + '">이전</a>';
}
for (var i = 1; i <= totalPages; i++) {
if (i === currentPage) {
paginationHtml += '<strong class="page-link active">' + i + '</strong>';
} else {
paginationHtml += '<a href="#" class="page-link" data-page="' + i + '">' + i + '</a>';
}
}
if (currentPage < totalPages) {
paginationHtml += '<a href="#" class="page-link" data-page="' + (currentPage + 1) + '">다음</a>';
}
$('.pagination').html(paginationHtml);
}
});
사용자가 정렬 기준인 라디오 버튼을 누르거나,
페이지네이션 버튼을 누르는 경우 현재 페이지와 / 정렬 기준을 보내서 함수를 실행시킨다.
이때 정렬 버튼은 무조건 1페이지가 로드되게 작성했다.
제품 로드 함수가 실행이 되면 ajax로 비동기 요청을 컨트롤러에게 보낸다.
컨트롤러에서는 Map으로 데이터를 보내기 때문에
ajax 요청 성공 시에는 응답받은 데이터 중 productList를 추출하고,
이 List를 반복문을 돌려 변수에 담고 기존 상품 목록을 empty()로 지워주고,
append()로 추가해 준다.
이후 비동기로 페이지네이션 버튼도 업데이트되어야 하기 때문에
현재 페이지와 전체 페이지 수를 보내서 페이지네이션 버튼을 변경하는 함수를 실행시킨다.
// ProductList (초기 데이터 로드 컨트롤러)
package com.korebap.app.view.page;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import com.korebap.app.biz.product.ProductDTO;
import com.korebap.app.biz.product.ProductService;
@Controller
public class ProductListPageAction {
@Autowired
private ProductService productService;
@RequestMapping(value="/productListPage.do", method=RequestMethod.GET)
public String productListPage(ProductDTO productDTO, Model model, @RequestParam(value="currentPage", defaultValue="1") int product_page_num) {
// 인자로 받는 cruuentPage는 defaultValue를 설정하여 값이 없는 경우 1로 설정한다.
// *초기 페이지이므로 null로 들어올 수 있음
// [ 상품 목록 페이지 ]
System.out.println("************************************************************[com.korebap.app.view.page productListPage 시작]************************************************************");
// 데이터 로그
System.out.println("*****com.korebap.app.view.page productListPage productDTO ["+productDTO+"]*****");
System.out.println("*****com.korebap.app.view.page productListPage product_page_num ["+product_page_num+"]*****");
// V에서 받아온 파라미터
String product_searchKeyword = productDTO.getProduct_searchKeyword(); // 검색어
String product_location = productDTO.getProduct_location(); // 상품 장소 (바다,민물)
String product_category = productDTO.getProduct_category(); // 상품 유형 (낚시배, 낚시터, 바다, 민물)
String product_search_criteria = productDTO.getProduct_search_criteria(); // 최신, 좋아요, 찜, 예약 많은 순 >> 정렬기준
// 데이터
System.out.println("*****com.korebap.app.view.page productListPage product_searchKeyword ["+product_searchKeyword+"]*****");
System.out.println("*****com.korebap.app.view.page productListPage product_location ["+product_location+"]*****");
System.out.println("*****com.korebap.app.view.page productListPage product_category ["+product_category+"]*****");
System.out.println("*****com.korebap.app.view.page productListPage product_search_criteria ["+product_search_criteria+"]*****");
// 사용자가 선택한 페이지번호 처리
productDTO.setProduct_page_num(product_page_num);
// M에게 데이터를 보내주고 결과를 받음
List<ProductDTO> productList = productService.selectAll(productDTO);
System.out.println("*****com.korebap.app.view.page productListPage productList ["+productList+"]*****");
// [전체 페이지 개수 받아오기]
productDTO.setProduct_condition("PRODUCT_PAGE_COUNT");
productDTO = productService.selectOne(productDTO);
// int 타입 변수에 받아온 값을 넣어준다.
int product_total_page = productDTO.getProduct_total_page();
System.out.println("*****com.korebap.app.view.page productListPage product_total_page ["+product_total_page+"]*****");
// 전체 페이지 수가 1보다 작다면
if (product_total_page < 1) {
product_total_page = 1; // 최소 페이지 수를 1로 설정
}
// 모델에 데이터 추가
model.addAttribute("productList", productList); // 상품 목록
model.addAttribute("product_location", product_location); // 위치 필터
model.addAttribute("product_category", product_category); // 카테고리 필터
model.addAttribute("searchOption", product_search_criteria); // 검색 기준
model.addAttribute("product_page_count", product_total_page); // 페이지 수
model.addAttribute("currentPage", product_page_num); // 현재 페이지
System.out.println("************************************************************[com.korebap.app.view.page productListPage 종료]************************************************************");
return "productList";
}
}
// ProductList (비동기 컨트롤러)
package com.korebap.app.view.async;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import com.korebap.app.biz.product.ProductDTO;
import com.korebap.app.biz.product.ProductService;
@RestController
public class ProductAsyncController {
@Autowired
private ProductService productService;
@RequestMapping(value="/productList.do", method=RequestMethod.GET)
public @ResponseBody Map<String, Object> productPage(ProductDTO productDTO,
@RequestParam(value="currentPage", required = false, defaultValue = "1") int current_page) {
System.out.println("************************************************************[com.korebap.app.view.async productPage (비동기) 시작]************************************************************");
// [ 상품 페이지네이션]
// 데이터로그
System.out.println("*****com.korebap.app.view.async productPage 비동기 currentPage [" + current_page + "]*****");
System.out.println("*****com.korebap.app.view.async productPage 비동기 product_searchKeyword [" + productDTO.getProduct_searchKeyword() + "]*****");
System.out.println("*****com.korebap.app.view.async productPage 비동기 product_location [" + productDTO.getProduct_location() + "]*****");
System.out.println("*****com.korebap.app.view.async productPage 비동기 product_category [" + productDTO.getProduct_category() + "]*****");
System.out.println("*****com.korebap.app.view.async productPage 비동기 product_search_criteria [" + productDTO.getProduct_search_criteria() + "]*****");
System.out.println("*****com.korebap.app.view.async productPage 비동기 Product_page_num [" + productDTO.getProduct_page_num() + "]*****");
// 카테고리와 위치에 대한 기본값 설정
if (productDTO.getProduct_category() == null || productDTO.getProduct_category().isEmpty()) {
productDTO.setProduct_category(null); // 필터 적용하지 않음
}
if (productDTO.getProduct_location() == null || productDTO.getProduct_location().isEmpty()) {
productDTO.setProduct_location(null); // 필터 적용하지 않음
}
//M에게 데이터를 보내주고, 결과를 ArrayList로 반환받는다.
productDTO.setProduct_page_num(current_page);
List<ProductDTO> productList = productService.selectAll(productDTO);
System.out.println("*****com.korebap.app.view.async productPage 비동기 productList [" + productList + "]*****");
// [게시판 페이지 전체 개수]
productDTO.setProduct_condition("PRODUCT_PAGE_COUNT");
productDTO = productService.selectOne(productDTO);
// int 타입 변수에 받아온 값을 넣어준다.
int product_total_page = productDTO.getProduct_total_page();
System.out.println("*****com.korebap.app.view.async productPage 비동기 product_total_page [" + product_total_page + "]*****");
// 현재 페이지 > 전체 페이지
if (current_page > product_total_page) {
System.out.println("*****com.korebap.app.view.async productPage 비동기 : 마지막 페이지 요청, 더 이상 데이터 없음");
return null;
}
// 결과를 Map에 담아 반환
Map<String, Object> responseMap = new HashMap<>();
responseMap.put("productList", productList);
responseMap.put("product_page_count", product_total_page);
responseMap.put("currentPage", current_page);
System.out.println("*****com.korebap.app.view.async productPage 비동기 responseMap" +responseMap+ "]");
System.out.println("************************************************************[com.korebap.app.view.async productPage (비동기) 종료]************************************************************");
return responseMap;
}
}
비동기 컨트롤러는 ajax 요청에 대한 응답 반환을 해야 하므로
ViewResolver가 관여하지 않도록 @RestController를 달아주었다.
페이지네이션 데이터가 비동기로 추가되는 것이기 때문에,
받아온 현재 페이지가 총 페이지 수보다 크다면 null을 반환해 주었다.
동작 화면
'Project' 카테고리의 다른 글
[Spring] 최종 팀 프로젝트 : 최종 발표 (1) | 2024.11.17 |
---|---|
[Spring] 최종 팀 프로젝트 : 무한스크롤 (1) | 2024.11.06 |
[Spring] 최종 팀 프로젝트 : 컨트롤러 Spring 이관 (1) | 2024.11.01 |
[Spring] 최종 팀 프로젝트 : 횡단 관심_트랜잭션 적용 (0) | 2024.10.28 |
[JSP] 중간 팀 프로젝트 : 낚시 예약 및 웹 커뮤니티 (마무리) (1) | 2024.10.04 |