개발/Spring

페이징 구현하기(pagination)

함수형 인간 2025. 3. 28. 00:31

페이징 요청의 흐름

  1. 사용자가 브라우저에서 목록 페이지를 요청하거나, 페이지 번호 링크를 클릭하거나, 검색 버튼을 클릭한다.
    이때 요청과 함께 currentPage, searchType, searchWord 파라미터가 함께 전달된다.
  2. Controller에서 해당되는 메소드가 요청을 받는다.
    @RequestParam과 @ModelAttribute를 통해 전달받은 파라미터들은 PaginationInfo 객체에 담긴다.
    (파라미터가 없을시 currentPage는 1로 초기화 하도록 설정)
  3. Service를 호출하여 DB에서 페이징 및 검색 조건에 맞는 데이터를 조회한다.
  4. 컨트롤러에서 DefaultPaginationRenderer를 생성하고 renderPagination 메소드를 이용하여
    페이징UI로 사용될 HTML 문자열을 만든다.
  5. 컨트롤러는 조회된 목록, 페이징UI HTML, 그리고 검색 조건을 Model에 담아 View로 전달.
  6. View 는 전달받은 데이터로 상품 테이블을 그리고, ${pagingHTML}을 이용해 페이징 링크들을 표시하며, 검색 UI의 초기값을 설정.
  7. 사용자가 페이징 링크를 클릭하면 클릭한 페이지의 번호를 currentPage 변수 혹은 input 태그에 담고 요청을 전송한다.(아래 예제에서는 paging() 자바스크립트 함수가 호출되어, 숨겨진 폼의 currentPage를 업데이트하고 폼을 전송)
  8. 사용자가 검색 버튼을 클릭하면 검색 조건을 변수 혹은 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  설명:

  1. totalRecord (int)
    • 의미: 전체 데이터(게시물, 상품 등)의 총 개수
  2. recordCount (int)
    • 의미: 한 페이지에 보여줄 데이터(레코드)의 개수.
    • 설정 방법: 개발자가 결정하는 값으로, 생성자(PaginationInfo(recordCount, pageSize))를 통해 초기화됩니다. (기본 생성자에서는 10으로 설정)
  3. pageSize (int)
    • 의미: 페이징 UI (예: [1][2][3][4][5])에서 한 번에 보여줄 페이지 번호 링크의 개수입니다. '블록 크기(Block Size)'라고도 한다.
  4. totalPage (int)
    • 의미: 전체 데이터를 recordCount 개씩 나누어 보여줄 때 필요한 총 페이지의 수.
    • setTotalRecord() 메서드 내에서 totalRecord와 recordCount를 이용하여 (totalRecord + recordCount - 1) / recordCount 공식으로 자동 계산됨.
  5. currentPage (int)
    • 의미: 사용자가 현재 보고 있는 페이지의 번호.
    • 클라이언트의 요청 파라미터값을 받아 setCurrentPage() 메서드를 통해 외부에서 설정. (기본값은 1)
  6. firstRecord (int)
    • 의미: 현재 페이지(currentPage)에 표시되는 데이터 목록 중 첫 번째 데이터의 순번. (예: currentPage가 2이고 recordCount가 10이면, 11번째 데이터)
    • setCurrentPage() 메서드 내에서 currentPage와 recordCount를 이용하여 자동 계산됨. 
  7. lastRecord (int)
    • 의미: 현재 페이지(currentPage)에 표시되는 데이터 목록 중 마지막 데이터의 순번. (예: currentPage가 2이고 recordCount가 10이면, 20번째 데이터)
    • setCurrentPage() 메서드 내에서 currentPage와 recordCount를 이용하여 자동 계산됨.
  8. firstPage (int)
    • 의미: 페이징 UI에 표시되는 페이지 번호 구간의 시작 번호. (예: pageSize가 5이고 currentPage가 7이면 firstPage는 6)
    • setCurrentPage() 메서드 내에서 currentPage와 pageSize를 이용하여 자동 계산.
  9. lastPage (int)
    • 의미: 페이징 UI에 표시되는 페이지 번호 구간의 마지막 번호. (예: pageSize가 5이고 currentPage가 7이면 lastPage는 10)
    • setCurrentPage() 메서드 내에서 currentPage와 pageSize를 이용하여 자동 계산.
    • 계산된 lastPage 값이 실제 totalPage보다 클 수 있다. 화면에 페이지 번호 링크를 그릴 때는 totalPage를 넘지 않도록 조정.
  10. simpleCondition (SimpleCondition)
    • 의미: 검색 조건(예: 검색 유형, 검색어 등)을 담는 객체.


SimpleCondition : 검색 조건을 담은 객체

@Data
public class SimpleCondition {
	private String searchType;
	private String searchWord;
}
  1.  
  1. searchType(String)
    • 의미: 검색의 조건이 되는 유형 (예 : 제목, 작성자 등)
  2. 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();
	}

}

 

  1. PaginationInfo 객체로부터 현재 페이지, 전체 페이지 수, 현재 페이지 블록의 시작/끝 번호 등의 정보를 가져온다.
  2. 이 정보를 바탕으로 "[이전]", 페이지 번호 목록, "[다음]" 링크 HTML을 만든다.
  3. 현재 페이지 번호는 링크 없이 텍스트로 표시하고, 다른 페이지 번호는 클릭 시 지정된 자바스크립트 함수(funcName)를 호출하는 <a> 태그 링크로 만든다.
  4. 최종적으로 생성된 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>

 

  1. 결과 매핑 (<resultMap id="prodMap">): DB 조회 결과를 ProdVO 자바 객체로 변환하며, 관련된 BuyerVO나 LprodVO 정보도 자동으로 매핑.
  2. 동적 검색 조건 (<sql id="searchFrag">): 검색 유형(searchType)과 검색어(searchWord)에 따라 WHERE 절을 동적으로 생성하는 재사용 가능한 SQL 조각을 정의.
  3. 전체 개수 조회 (selectTotalRecord): 검색 조건에 맞는 상품 데이터의 총 개수를 조회하는 쿼리. 페이징 계산에 사용.
  4. 목록 조회 및 페이징 (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>

 

 

  1. 상품 목록 표시: 컨트롤러에서 전달받은 상품 목록(prodList)을 JSTL (<c:forEach>)을 사용하여 테이블 형태로 화면에 보여준다. 상품이 없을 경우 "상품 없음" 메시지를 표시합니다.
  2. 검색 UI 제공: 테이블 하단에 검색 조건(searchType 드롭다운), 검색어(searchWord 입력 필드), "검색" 버튼(searchBtn)을 제공.
  3. 페이징 표시: 컨트롤러에서 생성한 페이징 HTML 문자열(${pagingHTML})을 테이블 하단에 출력하여 페이지 이동 링크를 보여준다.
  4. 상태 관리 및 요청:
    • 눈에 보이지 않는 숨겨진 폼 (#searchForm)을 사용하여 현재 페이지 번호(currentPage)와 검색 조건(searchType, searchWord)을 관리.
    • JavaScript:
      • 페이지 로딩 시, 이전 검색 조건을 검색 UI에 다시 표시.
      • 페이징 링크 클릭 시: paging() 함수가 호출되어 숨겨진 폼의 currentPage 값을 변경하고 폼을 전송하여 해당 페이지 조회를 요청.
      • 검색 버튼 클릭 시: 검색 UI에 입력된 값을 숨겨진 폼으로 복사한 후 폼을 전송하여 새로운 검색 결과를 요청.