그간 웹개발을 공부하면서 , 다수의 프로젝트들을 만들었었는데
항상 빠지지않고 사용했던게 바로 페이징인 것 같다.
이젠 많이 어렵지 않다고 느낄 정도로 많이 사용해서 익숙해졌지만 정리한 적이 없어 한번 정리해보려고 한다.
예제는 제작한 쇼핑몰 프로젝트이다.
[스프링] 쇼핑몰 - 상품목록과 페이징 :: 간편 웹프로그래밍 (tistory.com)
페이징에 필요한 파라미터를 받을 VO
public class ItemCriteria
{
private int page;
private int perPageNum;
private int rowStart;
private int rowEnd;
private String catemain;
private String catesub;
private String sort;
/* */ public String getSort() {
/* 16 */ return this.sort;
/* */ }
/* */
/* */ public void setSort(String sort) {
/* 20 */ this.sort = sort;
/* */ }
/* */
/* */ public String getCatemain() {
/* 24 */ return this.catemain;
/* */ }
/* */
/* */ public void setCatemain(String catemain) {
/* 28 */ this.catemain = catemain;
/* */ }
/* */
/* */ public String getCatesub() {
/* 32 */ return this.catesub;
/* */ }
/* */
/* */ public void setCatesub(String catesub) {
/* 36 */ this.catesub = catesub;
/* */ }
/* */
/* */ public ItemCriteria() {
/* 40 */ this.page = 1;
/* 41 */ this.perPageNum = 8;
/* */ }
/* */
/* */ public void setPage(int page) {
/* 45 */ if (page <= 0) {
/* 46 */ this.page = 1;
/* */ return;
/* */ }
/* 49 */ this.page = page;
/* */ }
/* */
/* */ public void setPerPageNum(int perPageNum) {
/* 53 */ if (perPageNum <= 0 || perPageNum > 100) {
/* 54 */ this.perPageNum = 10;
/* */ return;
/* */ }
/* 57 */ this.perPageNum = perPageNum;
/* */ }
/* */
/* */ public int getPage() {
/* 61 */ return this.page;
/* */ }
/* */
/* */ public int getPageStart() {
/* 65 */ return (this.page - 1) * this.perPageNum;
/* */ }
/* */
/* */ public int getPerPageNum() {
/* 69 */ return this.perPageNum;
/* */ }
/* */
/* */ public int getRowStart() {
/* 73 */ this.rowStart = ((this.page - 1) * this.perPageNum) + 1;
/* 74 */ return this.rowStart;
/* */ }
/* */
/* */ public int getRowEnd() {
/* 78 */ this.rowEnd = this.rowStart + this.perPageNum - 1;
/* 79 */ return this.rowEnd;
/* */ }
/* */
/* */
/* */ public String toString() {
/* 84 */ return "Criteria [page=" + this.page + ", perPageNum=" + this.perPageNum + ", rowStart="
+ this.rowStart + ", rowEnd=" + this.rowEnd +
/* 85 */ "]";
/* */ }
/* */ }
}
페이징에 필요한 파라미터를 받아서 페이징버튼을 만들 클래스
package kr.co.vo;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
public class ItemPageMaker {
private int totalCount;
private int startPage;
private int endPage;
private boolean prev;
private boolean next;
private int displayPageNum = 10;
private ItemCriteria cri;
public void setCri(ItemCriteria cri) {
this.cri = cri;
}
public void setTotalCount(int totalCount) {
this.totalCount = totalCount;
calcData();
}
public int getTotalCount() {
return totalCount;
}
public int getStartPage() {
return startPage;
}
public int getEndPage() {
return endPage;
}
public boolean isPrev() {
return prev;
}
public boolean isNext() {
return next;
}
public int getDisplayPageNum() {
return displayPageNum;
}
public ItemCriteria getCri() {
return cri;
}
private void calcData() {
endPage = (int) (Math.ceil(cri.getPage() / (double)displayPageNum) * displayPageNum);
startPage = (endPage - displayPageNum) + 1;
int tempEndPage = (int) (Math.ceil(totalCount / (double)cri.getPerPageNum()));
if (endPage > tempEndPage) {
endPage = tempEndPage;
}
prev = startPage == 1 ? false : true;
next = endPage * cri.getPerPageNum() >= totalCount ? false : true;
}
public String makeQuery(int page) {
UriComponents uriComponents =
UriComponentsBuilder.newInstance()
.queryParam("page", page)
.queryParam("perPageNum", cri.getPerPageNum())
.queryParam("catemain", cri.getCatemain())
.queryParam("catesub", cri.getCatesub())
.queryParam("sort", cri.getSort())
.build();
return uriComponents.toUriString();
}
public String makeOrderQuery(int page) {
UriComponents uriComponents =
UriComponentsBuilder.newInstance()
.queryParam("page", page)
.queryParam("perPageNum", cri.getPerPageNum())
.queryParam("delivstate", cri.getCatemain())
.build();
return uriComponents.toUriString();
}
}
Mapper
<select id="itemList" resultType="kr.co.vo.ItemVO"
parameterType="kr.co.vo.ItemCriteria">
SELECT ITEM_NO , ITEM_NAME , ITEM_PRICE , ITEM_SIZE, ITEM_COLOR,
ITEM_DISC, ITEM_CONTENT
, ITEM_IMGMAIN, ITEM_IMGSUB, ITEM_DATE,
ITEM_STAR, ITEM_CATEMAIN,
ITEM_CATESUB
FROM (
SELECT ITEM_NO , ITEM_NAME
, ITEM_PRICE , ITEM_SIZE, ITEM_COLOR, ITEM_DISC,
ITEM_CONTENT
,
ITEM_IMGMAIN, ITEM_IMGSUB,ITEM_DATE, ITEM_STAR, ITEM_CATEMAIN,
ITEM_CATESUB,
ROW_NUMBER() OVER(
<include refid="sort" />
) AS RNUM
FROM ITEM
WHERE ITEM_CATEMAIN =
<include refid="catemain" />
AND
ITEM_CATESUB =
<include refid="catesub" />
)
WHERE RNUM BETWEEN #{rowStart} AND #{rowEnd}
<include refid="sort" />
</select>
<!-- 페이징에 필요한 글의 총 개수 -->
<select id="itemCount" resultType="int" parameterType="kr.co.vo.ItemCriteria">
SELECT COUNT(*) FROM (select * from ITEM where
ITEM_CATEMAIN =
<include refid="catemain"/>
AND
ITEM_CATESUB =
<include refid="catesub"/>
) WHERE
<![CDATA[
ITEM_NO > 0
]]>
</select>
<!-- 정렬 -->
<sql id="sort">
<if test="sort == '' ">
ORDER BY ITEM_NO DESC
</if>
<if test="sort == 'no' ">
ORDER BY ITEM_NO DESC
</if>
<if test="sort == 'pricedesc' ">
ORDER BY ITEM_PRICE DESC
</if>
<if test="sort == 'priceasc' ">
ORDER BY ITEM_PRICE ASC
</if>
</sql>
<!-- 메인카테고리 -->
<sql id="catemain">
<if test="catemain == ''">
'item'
</if>
<if test="catemain == 'item'">
'item'
</if>
<if test="catemain == 'main'">
'main'
</if>
<if test="catemain == 'popular'">
'popular'
</if>
</sql>
<!-- 서브카테고리 -->
<sql id="catesub">
<if test="catesub == 'outer' ">
'jumper' OR ITEM_CATESUB = 'coat' OR ITEM_CATESUB =
'jacket' OR ITEM_CATESUB
= 'padding'
</if>
<if test="catesub == 'top' ">
'long' OR ITEM_CATESUB = 'knit' OR ITEM_CATESUB =
'halftee' OR ITEM_CATESUB
= 'shirts' OR ITEM_CATESUB = 'blank'
</if>
<if test="catesub == 'bottom' ">
'pants' OR ITEM_CATESUB = 'denim' OR ITEM_CATESUB =
'halfpants' OR
ITEM_CATESUB = 'jogger' OR ITEM_CATESUB = 'sult'
</if>
<if test="catesub == 'shoes' ">
'sneakers' OR ITEM_CATESUB = 'boots' OR ITEM_CATESUB =
'walker'
OR ITEM_CATESUB = 'derby' OR ITEM_CATESUB = 'sandal'
</if>
<if test="catesub == 'jumper'">
'jumper'
</if>
<if test="catesub == 'coat'">
'coat'
</if>
<if test="catesub == 'jacket'">
'jacket'
</if>
<if test="catesub == 'padding'">
'padding'
</if>
<if test="catesub == 'long'">
'long'
</if>
<if test="catesub == 'knit'">
'knit'
</if>
<if test="catesub == 'halftee'">
'halftee'
</if>
<if test="catesub == 'shirts'">
'shirts'
</if>
<if test="catesub == 'blank'">
'blank'
</if>
<if test="catesub == 'pants'">
'pants'
</if>
<if test="catesub == 'denim'">
'denim'
</if>
<if test="catesub == 'halfpants'">
'halfpants'
</if>
<if test="catesub == 'jogger'">
'jogger'
</if>
<if test="catesub == 'sult'">
'sult'
</if>
<if test="catesub == 'sneakers'">
'sneakers'
</if>
<if test="catesub == 'boots'">
'boots'
</if>
<if test="catesub == 'walker'">
'walker'
</if>
<if test="catesub == 'derby'">
'derby'
</if>
<if test="catesub == 'sandal'">
'sandal'
</if>
</sql>
먼저,
페이징에는 고정된 파라미터가 필요하다.
- 현재 페이지 수 int page ;
- 출력 할 개수 int perPageNum;
- 출력을 시작 할 번호 int rowStart;
- 출력을 끝낼 번호 int rowEnd;
이다.
ItemCriteria에서, 기본 페이지 수를 page = 1; , perPageNum = 8을 해줬다.
때문에 따로 값을 입력하지 않으면 1페이지에 8개의 값이 출력된다.
이제 getRowStart()를 보자.
rowStart와 rowEnd는 게시글을 몇번부터 몇번까지 출력하는지를 계산하는 메서드이다.
위의 두가지 파라미터를 기준으로 DB에서 페이지에 내보낼 수를 계산하는데
직접 계산해보면 간단하다.
/* */ public int getRowStart() {
/* 73 */ this.rowStart = ((this.page - 1) * this.perPageNum) + 1;
/* 74 */ return this.rowStart;
/* */ }
/* */
/* */ public int getRowEnd() {
/* 78 */ this.rowEnd = this.rowStart + this.perPageNum - 1;
/* 79 */ return this.rowEnd;
/* */ }
현재 페이지가 1일때,
rowStart = ( (1 - 1) * 8 ) + 1 = 1
rowEnd = 1 + 7 = 8
1번~ 8번 , 총 8개의 값을 출력
------
현재 페이지가 2일때,
rowStart = ( (2 - 1) * 8 ) + 1 = 9
rowEnd = 9 + 7 = 16
------
현재 페이지가 3일때,
rowStart = ( (3 - 1) * 8 ) + 1 = 17
rowEnd = 17 + 7 = 24
이렇게 page , perPageNum 두가지 파라미터로 계산되어 값이 Mapper에 전달된다.
Oracle의 경우 BETWEEN을 사용하기도 하고,
MYSQL의 경우 LIMIT 를 이용해 페이징을 하기도 한다.
페이징도 여러가지 방법이 존재한다.
Mapper의 itemList에서, 가장 뒤에보면
WHERE RNUM BETWEEN #{rowStart} AND #{rowEnd}
라는 WHERE문 쿼리가 있다.
쿼리를 살펴보면, 서브쿼리 안에 ROW_NUMBER() 를 이용하는데 ,
ROW_NUMBER()는 () 안에 order by 절을 필수로 입력해줘야 하며,
그 정렬순서에 따라 값을 0부터 차례대로 1씩 숫자를 매긴다.
페이징을 공부하던 중에는, 이런 생각을 한 적이 있다.
여차피 페이징이 반복적인, 다수의 데이터가 쌓이는 테이블은
시퀸스등을 이용하여 Primary KEY를 +1씩 시켜서 중복을 방지하여 기본키제약조건에 위배되지 않게 하는데,
왜 굳이 다시 ROW_NUMBER()를 사용해서 값을 순차적으로 다시 매겨주는걸까?
그 답은 직접 구현해보면서 찾았다.
ROW_NUMBER()를 빼고 그냥 PR이 쌓이는 순서대로 페이징을 하니 페이징은 제대로 먹혔다.
문제는 2페이지에는 데이터가 정상적으로 8개가 찍히는데
3페이지에는 데이터가 6개밖에 찍히지않고,
4페이지에는 데이터가 다시 정상적으로 8개가 찍히는등 페이지별로 데이터가 들쭉날쭉했다.
그 답은 데이터의 변형에 있었다.
ROW_NUMBER()를 해주지 않으면 중간에 글이 삭제되거나 수정됐을때,
1페이지에 PR이 1~8인 게시글을 불러와야하는데 2번 4번 글이 삭제되어서
1 ,3 ,5, 6, 7,8 번 글만 출력이 되었던 것이다.
그래서 서브쿼리 속 ROW_NUMBER()를 이용해 테이블의 속성들을 다시 순차적으로 정렬시켜서,
1페이지에 1,3,5,6,7,8,9,10 번의 8개의 게시글이 출력되도록 해줘야 하겠다.
정렬 기준이나, where 문의 변경이 필요하기 때문에 사용한 동적쿼리에 대해서는 설명을 생략하겠다.
이제 페이지의 버튼을 만들어주는 PageMaker 클래스에 대해 설명 해보겠다.
우선 , 버튼은
- 총 게시글 수 75개
- 총 게시글 수 132개
- 총 게시글 수 217개
[1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [다음]
[이전] [11] [12] [13] [14] [15] [16] [17] [18] [19] [20] [다음]
[21] [22]
현재 페이지의 번호가 1~10 사이라면 시작 번호는 1이여야 한다.
현재 페이지의 번호가 11~20 사이라면 시작 번호는 11이여야 한다.
이런 로직으로 출력이 된다.
private void calcData() {
endPage = (int) (Math.ceil(cri.getPage() / (double)displayPageNum) * displayPageNum);
startPage = (endPage - displayPageNum) + 1;
int tempEndPage = (int) (Math.ceil(totalCount / (double)cri.getPerPageNum()));
if (endPage > tempEndPage) {
endPage = tempEndPage;
}
prev = startPage == 1 ? false : true;
next = endPage * cri.getPerPageNum() >= totalCount ? false : true;
}
해당 메서드를 살펴보자.
totalCount는 Mapper의 select Count(*) 문을 이용해 구한다. 일단 75라고 생각해보자.
displayPageNum 은 표시 이전과 다음 버튼 사이에 표시 될 페이지의 개수이다.
* Math.ceil() 함수는 주어진 숫자보다 크거나 같은 숫자 중 가장 작은 숫자를 integer 로 반환한다. 쉽게말해 올림이다.
계산해보면
endPage = (올림 ( 1 / 10 ) * 10 = 10
startPage = (10 - 10 ) + 1 = 1
int tempEndPage = (올림 ( 75 / 8 ) ) = 9.375 = 10
prev ( 이전버튼 ) = startPage가 1일때 false, 아니면 true;
next( 다음버튼 ) = 10 * 8 이 totalCount 보다 크면 false 아니면 true 이다.
이전, 다음 버튼은 ture일때만 버튼을 출력한다.
끝페이지와 시작페이지의 수를 계산해서, 출력된 버튼의 개수를 뽑아내고,
그 버튼의 값으로 이전버튼이나 다음버튼을 출력하는 것이다.
그 후 makeQuery 부분이다.
UriComponents를 이용해 쿼리를 만들어준다.
uriComponents의 사용법을 전에 정리한 적이 있다..
UriComponents과 URLEncoding :: 간편 웹프로그래밍 (tistory.com)
이제 Controller를 살펴보자.
@RequestMapping(value = {"/itemView"}, method = {RequestMethod.GET})
public String itemView(Model model, @ModelAttribute("cri") ItemCriteria cri) throws Exception {
model.addAttribute("itemList", this.mainService.itemList(cri));
ItemPageMaker pageMaker = new ItemPageMaker();
pageMaker.setCri(cri);
pageMaker.setTotalCount(this.mainService.itemCount(cri));
model.addAttribute("pageMaker", pageMaker);
return "/main/itemView";
}
먼저 @ModelAttribute 를 이용해 페이징과 url와 관련된 파라미터를 주고 받을 수 있게 매개변수에 넣어준다.
ItemCriteria 라는 VO로 받은 값을 페이징을 원하는 select문에 매개변수로 넣어서
원하는 페이지의 List 데이터를 Model을 이용해 뷰로 보내준다.
파라미터를 pageMaker에도 넣어서, 현재 페이지에 따라 페이징버튼이 변경되도록 해준다.
View
<div class="pagination font-alt">
<c:if test="${pageMaker.prev}">
<a href="/main/itemView${pageMaker.makeQuery(pageMaker.startPage - 1)}"><i class="fa fa-angle-left"></i></a>
</c:if>
<c:forEach begin="${pageMaker.startPage}" end="${pageMaker.endPage}" var="idx">
<a class="active" href="/main/itemView${pageMaker.makeQuery(idx)}">${idx}</a>
</c:forEach>
<c:if test="${pageMaker.next && pageMaker.endPage > 0}">
<a href="/main/itemView${pageMaker.makeQuery(pageMaker.endPage + 1)}"><i class="fa fa-angle-right"></i></a>
</c:if>
</div>
뷰의 페이징버튼 부분이다.
아까 pageMaker에서 다뤘던 prev 와 next 의 참/거짓 여부에 따라, 버튼이 출력되도록 하며
forEach를 이용해 pageMaker의 배열값을 idx순으로 출력되게 해준다.
반복적인 사용으로 숙달되어 어느 프로젝트를 하던, 페이징에 무리나 두려움이 없게 됐지만
막상 글로 쓰려고하니 또 헤갈리고 긴가민가한 부분이 있었다.
역시 한번 정리하고나니 더 제대로, 명확하게 이해가 된 것 같다.
웹개발을 처음 접하고 공부하던 중 만난 페이징은 첫번째 큰 산이였던 것 같다.
당시에는 이게 참 어렵고, 이해 할 자신이 없었는데
여러번 사용하고 응용도 해가며 기능을 완성하다보니 어느새인가 잘 다룰 수 있게 되었다.
물론 여러가지 방식이 있고 모두 알자면 끝이 없겠지만.. 최소한 두려움은 없다.
직접 계산해가며 한줄씩 읽어보면 그리 어렵지 않은데, 그 당시엔 왜그리 어려웠던 것일까.
어떤 어려운 로직, 알고리즘을 만나던 기죽지 않는 마음이 중요하다고 느끼게 해준 페이징이였다.
'SPRING > Spring' 카테고리의 다른 글
스프링을 왜 사용하는가? 에 대한 토론글 (1) | 2024.03.12 |
---|---|
스프링에서 ajax 사용의 유형들 (0) | 2021.10.30 |
[스프링] Session과 Cookie , HttpSession (2) | 2021.09.14 |
[스프링]HTTP프로토콜과 URI를 통한 요청 (0) | 2021.09.08 |
DataAccessException 란? -Spring 예외처리 (0) | 2021.08.12 |