개발/Spring
페이징 구현하기(pagination)
함수형 인간
2025. 3. 28. 00:31
페이징 요청의 흐름
- 사용자가 브라우저에서 목록 페이지를 요청하거나, 페이지 번호 링크를 클릭하거나, 검색 버튼을 클릭한다.
이때 요청과 함께 currentPage, searchType, searchWord 파라미터가 함께 전달된다. - Controller에서 해당되는 메소드가 요청을 받는다.
@RequestParam과 @ModelAttribute를 통해 전달받은 파라미터들은 PaginationInfo 객체에 담긴다.
(파라미터가 없을시 currentPage는 1로 초기화 하도록 설정) - Service를 호출하여 DB에서 페이징 및 검색 조건에 맞는 데이터를 조회한다.
- 컨트롤러에서 DefaultPaginationRenderer를 생성하고 renderPagination 메소드를 이용하여
페이징UI로 사용될 HTML 문자열을 만든다. - 컨트롤러는 조회된 목록, 페이징UI HTML, 그리고 검색 조건을 Model에 담아 View로 전달.
- View 는 전달받은 데이터로 상품 테이블을 그리고, ${pagingHTML}을 이용해 페이징 링크들을 표시하며, 검색 UI의 초기값을 설정.
- 사용자가 페이징 링크를 클릭하면 클릭한 페이지의 번호를 currentPage 변수 혹은 input 태그에 담고 요청을 전송한다.(아래 예제에서는 paging() 자바스크립트 함수가 호출되어, 숨겨진 폼의 currentPage를 업데이트하고 폼을 전송)
- 사용자가 검색 버튼을 클릭하면 검색 조건을 변수 혹은 input 태그에 담고 요청을 전송
참고사항
- 두 번의 DB 조회: 일반적으로 페이징 처리를 위해서는 1) 전체 개수를 알아내기 위한 COUNT 쿼리와 2) 현재 페이지 데이터를 가져오기 위한 LIMIT/OFFSET 쿼리, 이렇게 두 번의 DB 조회가 필요.
- 페이징 정보 객체화: 코드 가독성과 유지보수성을 위해 와 같이 페이징 관련 데이터를 하나의 객체( PaginationInfo )로 묶어 관리.
- 상태 유지: 페이지를 이동하더라도 사용자가 적용한 검색어나 필터 조건이 유지되도록 구현.
- Renderer 역할: PaginationRenderer는 뷰에서 페이징 UI 로직을 분리하여 코드를 더 깔끔하게 만들어주는 역할.
객체 종류 및 설명
PaginationInfo : 페이징에 활용될 데이터를 담은 객체
@Getter
@ToString
public class PaginationInfo {
public PaginationInfo() {
this(10, 5);
}
public PaginationInfo(int recordCount, int pageSize) {
super();
this.recordCount = recordCount;
this.pageSize = pageSize;
setCurrentPage(1);
}
private int totalRecord; // 전체 레코드 수(조회할 데이터) , 100
private int recordCount; // 한 페이지의 레코드 수(임의 결정), 10
private int pageSize; // 한 페이지에서 제공할 페이지 링크 수(임의 결정) , 5
private int totalPage; // 전체 페이지 수 , 10
private int currentPage; // 클라이언트 요구 페이지(요청 파라미터), 1
private int firstRecord; // 현재 페이지의 첫번째 레코드 번호, 1
private int lastRecord; // 현재 페이지의 마지막 레코드 번호, 10
private int firstPage; // 한 구간(block)에서 제공할 첫번째 페이지 번호 , 1
private int lastPage; // 한 구간(block)에서 제공할 마지막 페이지 번호 , 5
public void setTotalRecord(int totalRecord) {
this.totalRecord = totalRecord;
this.totalPage = (totalRecord + (recordCount - 1)) / recordCount;
}
public void setCurrentPage(int currentPage) {
this.currentPage = currentPage;
this.lastRecord = currentPage * recordCount;
this.firstRecord = lastRecord - (recordCount - 1);
this.lastPage = pageSize * ((currentPage+(pageSize-1)) / pageSize);
this.firstPage = lastPage - (pageSize - 1);
}
// 검색 조건
@Setter
private SimpleCondition simpleCondition;
}
PaginationInfo 설명:
- totalRecord (int)
- 의미: 전체 데이터(게시물, 상품 등)의 총 개수
- recordCount (int)
- 의미: 한 페이지에 보여줄 데이터(레코드)의 개수.
- 설정 방법: 개발자가 결정하는 값으로, 생성자(PaginationInfo(recordCount, pageSize))를 통해 초기화됩니다. (기본 생성자에서는 10으로 설정)
- pageSize (int)
- 의미: 페이징 UI (예: [1][2][3][4][5])에서 한 번에 보여줄 페이지 번호 링크의 개수입니다. '블록 크기(Block Size)'라고도 한다.
- totalPage (int)
- 의미: 전체 데이터를 recordCount 개씩 나누어 보여줄 때 필요한 총 페이지의 수.
- setTotalRecord() 메서드 내에서 totalRecord와 recordCount를 이용하여 (totalRecord + recordCount - 1) / recordCount 공식으로 자동 계산됨.
- currentPage (int)
- 의미: 사용자가 현재 보고 있는 페이지의 번호.
- 클라이언트의 요청 파라미터값을 받아 setCurrentPage() 메서드를 통해 외부에서 설정. (기본값은 1)
- firstRecord (int)
- 의미: 현재 페이지(currentPage)에 표시되는 데이터 목록 중 첫 번째 데이터의 순번. (예: currentPage가 2이고 recordCount가 10이면, 11번째 데이터)
- setCurrentPage() 메서드 내에서 currentPage와 recordCount를 이용하여 자동 계산됨.
- lastRecord (int)
- 의미: 현재 페이지(currentPage)에 표시되는 데이터 목록 중 마지막 데이터의 순번. (예: currentPage가 2이고 recordCount가 10이면, 20번째 데이터)
- setCurrentPage() 메서드 내에서 currentPage와 recordCount를 이용하여 자동 계산됨.
- firstPage (int)
- 의미: 페이징 UI에 표시되는 페이지 번호 구간의 시작 번호. (예: pageSize가 5이고 currentPage가 7이면 firstPage는 6)
- setCurrentPage() 메서드 내에서 currentPage와 pageSize를 이용하여 자동 계산.
- lastPage (int)
- 의미: 페이징 UI에 표시되는 페이지 번호 구간의 마지막 번호. (예: pageSize가 5이고 currentPage가 7이면 lastPage는 10)
- setCurrentPage() 메서드 내에서 currentPage와 pageSize를 이용하여 자동 계산.
- 계산된 lastPage 값이 실제 totalPage보다 클 수 있다. 화면에 페이지 번호 링크를 그릴 때는 totalPage를 넘지 않도록 조정.
- simpleCondition (SimpleCondition)
- 의미: 검색 조건(예: 검색 유형, 검색어 등)을 담는 객체.
- 의미: 검색 조건(예: 검색 유형, 검색어 등)을 담는 객체.
SimpleCondition : 검색 조건을 담은 객체
@Data
public class SimpleCondition {
private String searchType;
private String searchWord;
}
- searchType(String)
- 의미: 검색의 조건이 되는 유형 (예 : 제목, 작성자 등)
- searchWord(String)
- 의미: 검색시에 포함되어야 하는 단어
PaginationRenderer : 페이지 UI를 만드는 객체의 인터페이스
public interface PaginationRenderer {
public String renderPagination(PaginationInfo paging, String funcName);
}
DefaultPaginationRenderer : PaginationRenderer 의 실제 구현체로, 페이징UI로 사용할 HTML문자열을 생성.
public class DefaultPaginationRenderer implements PaginationRenderer {
@Override
public String renderPagination(PaginationInfo paging, String funcName) {
int firstPage = paging.getFirstPage();
int lastPage = paging.getLastPage();
int totalPage = paging.getTotalPage();
lastPage = lastPage > totalPage ? totalPage : lastPage;
int pageSize = paging.getPageSize();
int currentPage = paging.getCurrentPage();
StringBuffer html = new StringBuffer();
String pattern = "<a href='javascript:;' onclick='%s(%d);'>[%s]</a>";
if(firstPage > pageSize) {
html.append( String.format(pattern, funcName, firstPage - pageSize, "이전") );
}
for(int page = firstPage; page <= lastPage; page++) {
if(page==currentPage) {
html.append(page);
}else {
html.append( String.format(pattern, funcName, page, page) );
}
}
if(lastPage < totalPage) {
html.append( String.format(pattern, funcName, lastPage + 1, "다음") );
}
return html.toString();
}
}
- PaginationInfo 객체로부터 현재 페이지, 전체 페이지 수, 현재 페이지 블록의 시작/끝 번호 등의 정보를 가져온다.
- 이 정보를 바탕으로 "[이전]", 페이지 번호 목록, "[다음]" 링크 HTML을 만든다.
- 현재 페이지 번호는 링크 없이 텍스트로 표시하고, 다른 페이지 번호는 클릭 시 지정된 자바스크립트 함수(funcName)를 호출하는 <a> 태그 링크로 만든다.
- 최종적으로 생성된 HTML 문자열 전체를 반환한다.
Controller
@Slf4j
@Controller
public class ProdListController{
@Autowired
private ProdService service;
@RequestMapping("/prod/prodList.do")
public String prodList(Model model,
@RequestParam(required = false, defaultValue = "1") int currentPage
, @ModelAttribute("condition") SimpleCondition simpleCondition
){
PaginationInfo paging = new PaginationInfo(2, 2);
paging.setCurrentPage(currentPage);
paging.setSimpleCondition(simpleCondition);
List<ProdVO> prodList = service.retrieveProdList(paging);
// scope
model.addAttribute("prodList", prodList);
PaginationRenderer render = new DefaultPaginationRenderer();
model.addAttribute("pagingHTML", render.renderPagination(paging, "paging"));
// view
return "prod/prodList";
}
}
Service
@Service
@RequiredArgsConstructor
public class ProdServiceImpl implements ProdService {
private final ProdDAO dao;
@Override
public List<ProdVO> retrieveProdList(PaginationInfo paging) {
int totalRecord = dao.selectTotalRecord(paging);
paging.setTotalRecord(totalRecord);
return dao.selectProdList(paging);
}
DAO
@Mapper
public interface ProdDAO {
public List<ProdVO> selectProdList(PaginationInfo paging);
public int selectTotalRecord(PaginationInfo paging);
}
mapper
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="kr.or.ddit.prod.dao.ProdDAO">
<resultMap type="ProdVO" id="prodMap" autoMapping="true">
<id property="prodId" column="PROD_ID"/>
<association property="buyer" javaType="BuyerVO" autoMapping="true" />
<association property="lprod" javaType="LprodVO" autoMapping="true" />
<collection property="cartList" ofType="CartVO" autoMapping="true">
<association property="member" javaType="MemberVO" autoMapping="true" />
</collection>
</resultMap>
<sql id="searchFrag">
<where>
<if test="@org.apache.commons.lang3.StringUtils@isNotBlank(simpleCondition.searchWord)">
<choose>
<when test="simpleCondition.searchType eq 'prodLgu'">
INSTR(PROD_LGU, #{simpleCondition.searchWord}) > 0
</when>
<when test="simpleCondition.searchType eq 'prodBuyer'">
INSTR(PROD_BUYER, #{simpleCondition.searchWord}) > 0
</when>
<when test="simpleCondition.searchType eq 'prodName'">
INSTR(PROD_NAME, #{simpleCondition.searchWord}) > 0
</when>
<otherwise>
INSTR(PROD_LGU, #{simpleCondition.searchWord}) > 0
OR INSTR(PROD_BUYER, #{simpleCondition.searchWord}) > 0
OR INSTR(PROD_NAME, #{simpleCondition.searchWord}) > 0
</otherwise>
</choose>
</if>
</where>
</sql>
<select id="selectTotalRecord" resultType="int" parameterType="kr.or.ddit.paging.PaginationInfo">
SELECT COUNT(*)
FROM PROD
<include refid="searchFrag" />
</select>
<select id="selectProdList" resultMap="prodMap" parameterType="kr.or.ddit.paging.PaginationInfo">
SELECT B.*
FROM(
SELECT ROWNUM RNUM, A.*
FROM(
SELECT
PROD_ID, PROD_BUYER, PROD_LGU
, PROD_NAME, PROD_COST, PROD_PRICE
, PROD_MILEAGE, PROD_INSDATE, BUYER_NAME
, LPROD_NM
FROM PROD INNER JOIN BUYER ON (PROD_BUYER = BUYER_ID)
INNER JOIN LPROD ON (PROD_LGU = LPROD_GU)
<include refid="searchFrag" />
ORDER BY PROD.ROWID DESC
) A
) B
<![CDATA[
WHERE RNUM >= #{firstRecord} AND RNUM <= #{lastRecord}
]]>
</select>
</mapper>
- 결과 매핑 (<resultMap id="prodMap">): DB 조회 결과를 ProdVO 자바 객체로 변환하며, 관련된 BuyerVO나 LprodVO 정보도 자동으로 매핑.
- 동적 검색 조건 (<sql id="searchFrag">): 검색 유형(searchType)과 검색어(searchWord)에 따라 WHERE 절을 동적으로 생성하는 재사용 가능한 SQL 조각을 정의.
- 전체 개수 조회 (selectTotalRecord): 검색 조건에 맞는 상품 데이터의 총 개수를 조회하는 쿼리. 페이징 계산에 사용.
- 목록 조회 및 페이징 (selectProdList): 검색 조건과 PaginationInfo로부터 받은 페이징 정보(firstRecord, lastRecord)를 이용하여, Oracle의 ROWNUM을 사용한 방식으로 특정 페이지에 해당하는 상품 목록을 조회하는 쿼리.
View
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<h4>상품목록</h4>
<table class="table table-bordered">
<thead class="table-dark">
<tr>
<th>일련번호</th>
<th>상품명</th>
<th>분류명</th>
<th>거래처명</th>
<th>구매가</th>
<th>판매가</th>
<th>마일리지</th>
</tr>
</thead>
<tbody>
<c:if test="${not empty prodList }">
<c:forEach items="${prodList }" var="prod">
<tr>
<td>${prod.rnum }</td>
<td>
<c:url value="/prod/prodDetail.do" var="detailUrl">
<c:param name="what" value="${prod.prodId }" />
</c:url>
<a href="${detailUrl }">${prod.prodName }</a>
</td>
<td>${prod.lprod.lprodNm }</td>
<td>${prod.buyer.buyerName }</td>
<td>${prod.prodCost }</td>
<td>${prod.prodPrice }</td>
<td>${prod.prodMileage }</td>
</tr>
</c:forEach>
</c:if>
<c:if test="${empty prodList }">
<tr>
<td colspan="7">상품 없음.</td>
</tr>
</c:if>
</tbody>
<tfoot>
<tr>
<td colspan="7">
<div id="searchUI">
<select name="searchType">
<option value>전체</option>
<option value="prodBuyer">거래처</option>
<option value="prodLgu">분류</option>
<option value="prodName">상품명</option>
</select>
<input type="text" name="searchWord" />
<button id="searchBtn">검색</button>
</div>
${pagingHTML }
</td>
</tr>
</tfoot>
</table>
<form action="<c:url value='/prod/prodList.do'/>" id="searchForm">
<input type="text" name="searchType" />
<input type="text" name="searchWord" />
<input type="text" name="currentPage" />
</form>
<script>
$("[name='searchType']").val("${condition.searchType}")
$("[name='searchWord']").val("${condition.searchWord}")
function paging(page){
// location.href="?currentPage="+page;
searchForm.currentPage.value = page;
$searchForm.submit();
}
// searchBtn 을 클릭하면, searchUI 가 가진 모든 입력값을 searchForm 으로 복사하고, searchForm을 전송
const $searchForm = $("#searchForm");
$("#searchBtn").on("click", function(event){
let $searchUI = $(this).parents("#searchUI");
$searchUI.find(":input[name]").each(function(idx, ipt){
let name = this.name;
let value = $(this).val();
searchForm[name].value = value;
});
$searchForm.submit();
});
</script>
- 상품 목록 표시: 컨트롤러에서 전달받은 상품 목록(prodList)을 JSTL (<c:forEach>)을 사용하여 테이블 형태로 화면에 보여준다. 상품이 없을 경우 "상품 없음" 메시지를 표시합니다.
- 검색 UI 제공: 테이블 하단에 검색 조건(searchType 드롭다운), 검색어(searchWord 입력 필드), "검색" 버튼(searchBtn)을 제공.
- 페이징 표시: 컨트롤러에서 생성한 페이징 HTML 문자열(${pagingHTML})을 테이블 하단에 출력하여 페이지 이동 링크를 보여준다.
- 상태 관리 및 요청:
- 눈에 보이지 않는 숨겨진 폼 (#searchForm)을 사용하여 현재 페이지 번호(currentPage)와 검색 조건(searchType, searchWord)을 관리.
- JavaScript:
- 페이지 로딩 시, 이전 검색 조건을 검색 UI에 다시 표시.
- 페이징 링크 클릭 시: paging() 함수가 호출되어 숨겨진 폼의 currentPage 값을 변경하고 폼을 전송하여 해당 페이지 조회를 요청.
- 검색 버튼 클릭 시: 검색 UI에 입력된 값을 숨겨진 폼으로 복사한 후 폼을 전송하여 새로운 검색 결과를 요청.