[Spring framework & MyBatis] 페이징 처리하기
📘 이 글은 『자바 웹 개발 워크북 - 구멍가게 코딩단』 책을 학습하며 제 방식대로 정리한 내용입니다.
개인 학습 목적의 정리이며, 저작권은 원저작자에게 있습니다.
🤔 Intro
웹 페이지를 구성할 때, 여러 리스트를 화면에 묶어서 표현하기 위해서는 반드시 페이징 처리가 필요하다.
spring framework + MyBatis를 이용해서 페이징 처리를 어떻게 할 수 있을지 학습해보자.
😀 Start!
계획
우선 페이징 처리를 어떤 방식으로 구현해야 할 지를 생각해보기 위해, 페이지 화면에 어떤 요소가 필요할지를 고민해보자.
해당 페이지는 페이징 실습으로 만든 todoList 페이지이다.
- 한 페이지당 10개의 list가 존재한다.
- 페이지 바에는 뒤로 이동할 수 있는 Previous와 앞으로 이동할 수 있는 Next가 존재한다.
- 페이지 번호는 10개씩 표시된다.
- 단, 끝 번호의 경우 10개로 나눈 것보다 작으면 나눠진 것 만큼만 나와야 한다.
- 예를 들면, 전체 페이지가 75개인 경우 8까지만 나와야 한다.
구현
Mapper
- 우선 목록 페이지를 10개씩 가져와야 하므로 mapper를 구성해서 정보들을 가져와야 한다.
- mapper를 구성하는 것은 이 프로젝트가 MyBatis 기반이기 때문이다..
페이징 처리를 위한 sql을 작성할때는 다음과 같은 정보가 필요하다.
- 몇 개씩 가져올 것인지
- 몇 개를 스킵하고 몇 번부터 가져올 것인지
- 이를 위해 limit (skip), (fetch)를 사용할 수 있다.
예를 들어 살펴보자.
- 두 번째 페이지 (11~20)의 경우, skip은 10, fetch는 10이 된다.
- 다섯 번째 페이지 (41~50)의 경우, skip은 40, fetch는 10이 된다.
이를 적용한 sql문은 다음과 같다. 첫 번째 예시인 두 번째 페이지의 예시문이다.
1
SELECT * FROM 테이블 ORDER BY DESC limit 10,10;
전체 페이지를 쪼개서 보여주는 것이므로, 페이징 처리를 위해서는 전체 페이지의 갯수도 필요하다. 이를 위해서 다음과 같은 쿼리가 필요하다.
1
SELECT COUNT(id) FROM 테이블;
MyBatis 사용을 위한 Mapper.xml은 다음과 같이 정리할 수 있다.
1 2 3 4 5 6 7 8 9
<!-- 페이징 처리를 위한 select, limit을 통해 가져오는 데이터 수와 skip data 수를 구한다. --> <select id="selectList" resultType="org.zerock.springex.domain.TodoVO"> SELECT * FROM tbl_todo ORDER BY tno DESC limit #{skip}, #{size} </select> <!-- 전체 tno의 갯수를 가져오기 위함이다. --> <select id="getCount" resultType="int"> SELECT COUNT(tno) FROM tbl_todo </select>
DTO - RequestDTO
페이지 처리를 위해서는 기본적으로 필요한 정보가 있다. 앞에서 본 페이지 화면을 떠올려보자.
- 화면에 그리기 위한 “현재 페이지 번호”
- 한 페이지당 보이는 데이터의 수
이 두 개의 정보는 차후 유지보수의 편의성을 위해 DTO로 만들어서 파라미터로 넘겨주는 것이 좋다.
또한, 페이지 처리를 위해서는 다음 두 가지의 DTO가 필요하다.
- 페이지 번호와 데이터의 수를 “전달”하기 위한 RequestDTO
- RequestDTO를 바탕으로 보여주는 페이지 수(end값), previous & next 여부를 표시하기위한 응답 DTO인 ResponseDTO (실제로 화면에 그려지는 정보는 이 값을 이용한다.)
그림으로 나타내면 다음과 같은 관계를 가진다.
RequestDTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
@Builder @Data @AllArgsConstructor @NoArgsConstructor public class PageRequestDTO { @Builder.Default @Min(value = 1) @Positive private int page = 1; @Builder.Default @Min(value = 10) @Positive private int size = 10; public int getSkip() { return (page - 1) * size; } }
- 생성의 편리함을 위해 lombok의 Buider pattern을 이용하였다.
- min값과 positive 값을 둬서 “양수”만 페이지 번호가 될 수 있게 하였다.
Paging 처리 로직
ResponseDTO를 구성하기 전에, 페이징 처리를 위한 로직들을 이해해보자.
해당 정보들은 전부 화면을 그리기 위해 필요한 정보 들이다. 화면 모양을 기억하면서 해당 정보들이 어디에 어떻게 쓰이는지를 파악하면 이해가 쉬울 것이다:)
end (마지막 페이지)
- 마지막 페이지는 현재 페이지 번호를 size로 나눈 다음, 올림하고 size를 곱해야 한다.
- 예를 들어,현재 페이지가 11인 경우 size가 10이라면
- 11/10.0의 올림 => 2이고 2 * size = 20이 끝 번호가 되는 것이다.
1
this.end = (int)(Math.ceil(this.page/10.0)) * 10;
- 끝 번호를 먼저 구한다면, 시작 번호의 경우 size-1 한 값을 빼주기만 하면 되므로 구하기 쉽다.
start (첫번째 페이지)
1
this.start = this.end - 9; //size-1을 빼준 것이다.
last (진짜 마지막 페이지)
- 앞에서 이미 end값을 구했다만, 이는 start를 구하기 위한 임시 값이기에 갱신이 필요하다.
- 예를 들어, 10개씩 구성하지만 전체 페이지 수가 10으로 나누어 떨어지지 않을 경우, 마지막 페이지에는 10보다 작은 갯수의 페이지가 있을 수 있기 때문이다.
- 이를 위해 전체 페이지를 기준으로 한 last 값을 구하고, 이것이 end 보다 작을 경우 end값을 last 값으로 갱신해야 한다.
1
int last = (int)(Math.ceil((total/(double)size)));
prev (이전 페이지 존재 여부)
- 시작 페이지가 1이 아니라면 무조건 이전 페이지가 존재한다.
1
this.prev = this.start > 1;
next (다음 페이지 존재 여부)
- 다음 페이지는, 마지막 페이지와 페이지당 개수를 곱한 값이 전체 보다 작다면 다음 페이지가 존재한다.
1
this.next = total > this.end * this.size;
DTO - ResponseDTO
- 앞에서 사용한 로직들을 이용해서, 화면에 그릴 ResponseDTO 정보를 다음과 같이 구성할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class PageResponseDTO<E> {
private int page;
private int size;
private int total;
//시작 페이지 번호
private int start;
//끝 페이지 번호
private int end;
//이전 페이지 존재 여부
private boolean prev;
//다음 페이지 존재 여부
private boolean next;
//페이지 번호 목록 (dto 리스트)
private List<E> dtoList;
@Builder(builderMethodName = "withAll")
public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total) {
this.page = pageRequestDTO.getPage();
this.size = pageRequestDTO.getSize();
this.total = total;
this.dtoList = dtoList;
this.end = (int) (Math.ceil(page / (double) size)) * 10; // 현재 페이지를 기준으로 끝 페이지 계산
this.start = end - 9; // 현재 페이지 당 개수는 10개이므로, 9를 빼면 start 페이지를 구할 수 있다.
int last = (int) (Math.ceil(total / (double) size)) * 10; //전체 페이지에서 나눠서 끝값을 정한다.
this.end = end > last ? last : end; //end가 더 크다면, last가 진짜 끝 값이므로 여기에 맞춘다.
this.prev = this.start > 1; //1보다 크면 무조건 이전 페이지가 있는 것이다.
this.next = total > this.end * this.size; //next는 전체 개수가 아직 더 크면 있는 것이다.
}
}
PageResponseDTO 역시 생성의 편리함을 위해 Builder 패턴을 적용하였다.
해당 DTO를 ServiceImpl 클래스에서 사용하기 위해서는 다음과 같이 구성하면 된다.
1
2
3
4
5
6
7
8
9
int total = todoMapper.getCount(pageRequestDTO);
PageResponseDTO<TodoDTO> pageResponseDTO = PageResponseDTO.<TodoDTO>withAll()
.dtoList(dtoList)
.total(total)
.pageRequestDTO(pageRequestDTO)
.build();
return pageResponseDTO;
파라미터에 dtoList가 들어가기 때문에 Mapper에 todoMapper.selectList 를 구성해서 db에서 화면에 띄울 10개의 요소들을 가져오는 과정도 필요하다만, 이번 주제에서는 페이징 로직을 중점으로 보고 있기 때문에 이 부분은 생략하고, PageResponseDTO를 Service에서 어떻게 사용하는지만 간단히 잘라서 보도록 하자.
📌 참고로 중요한 로직이기에 JUnit Test를 작성해서 로직에서 정리한 각 변수들 (start, end…)을 잘 가져오는지 체크하는 것도 잊지 말자.
js & jsp - 페이지 이벤트 처리
- 구성한 페이지 번호들을 클릭했을 때, 해당 페이지의 list들이 뜰 수 있도록 js를 구성해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<ul class="pagination justify-content-center">
<!-- 이 부분이 이제 Previous가 나타나는 부분이다. -->
<c:if test="${responseDTO.prev}">
<li class="page-item">
<a class="page-link" data-num="${responseDTO.start - 1}">Previous</a>
</li>
</c:if>
<!-- page 로 넘어갈 수 있도록 forEach 구성 -->
<c:forEach begin="${responseDTO.start}" end="${responseDTO.end}" var="num">
<li class="page-item ${responseDTO.page == num? "active":""}">
<a class="page-link" data-num="${num}">${num}</a>
</li>
</c:forEach>
<!-- 이게 next를 구성하는 것이다. -->
<c:if test="${responseDTO.next}">
<li class="page-item">
<a class="page-link" data-num="${responseDTO.end + 1}">Next</a>
</li>
</c:if>
</ul>
- 편의를 위해 부트스트랩을 활용하여, 우리 DTO에 맞게 수정하였다.
- prev, next는 boolean 값이기에 if로 체크해서 표시하도록 하였다.
- 부트스트랩에서는 active를 통해 style을 적용할 수 있으므로, 해당 num이면 active로 페이지 칸이 색칠 되도록 구성하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 페이지 이동을 위한 스크립트 추가, data-num 변수를 이용한다. -->
<script>
document.querySelectorAll(".pagination").addEventListener('click', function(e) {
e.preventDefault() // 기본 동작을 막는다.
e.stopPropagation() // 이벤트 전파를 막는다.
const target = e.target; //클릭된 실제 DOM 요소를 가져온다.
//a 태그를 클릭했을때만 동작하도록 하기 위함이다.
if(target.tagName !== 'A'){
return;
}
// 저장한 data-num 속성값을 가져온다.
const num = target.getAttribute("data-num");
self.location = `/todo/list?page=\${num}`; //백틱을 이용해서 템플릿 처리한다.
//페이지 이동을 자바스크립트로 처리하기 위함이다.
//여기서 self는 현재 window 객체를 의미한다. (window.location과 동일하다.)
},false);
</script>
- js를 이용해서 클릭 이벤트를 처리한다
- href로 직접 링크를 주는 것보다, 동적 처리나 검색 필터링에 유리한 방식이다.
- 이를 통해, 해당 a태그의 page-num을 클릭했을때 location을 걸 수 있다.