🤔 페이징(Paging)?
많은양의 데이터가 데이터베이스에 저장되어 있고, 정장된 모든 데이터를 한번에 응답하려면 페이지 로딩 시간이 길어지며 사용자가 페이지를 스크롤 하거나 데이터를 찾는데 어려울 수 있습니다.
이러한 성능과, 사용자 경험을 향상시키기 위해 페이징을 사용하여 전체 데이터를 한번에 표시하지않고, 여러 페이지로 나누어 표시하는 기법입니다.
✔ Pageable
Spring Data 에서는 페이징 처리를 간편하게 할 수 있도록 Pageable Interface를 제공합니다.
구현체로 PageRequest class를 사용합니다.
🔎 PageRequest
package org.springframework.data.domain;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
public class PageRequest extends AbstractPageRequest {
private static final long serialVersionUID = -4541509938956089562L;
private final Sort sort;
protected PageRequest(int pageNumber, int pageSize, Sort sort) {
super(pageNumber, pageSize);
Assert.notNull(sort, "Sort must not be null");
this.sort = sort;
}
public static PageRequest of(int pageNumber, int pageSize) {
return of(pageNumber, pageSize, Sort.unsorted());
}
public static PageRequest of(int pageNumber, int pageSize, Sort sort) {
return new PageRequest(pageNumber, pageSize, sort);
}
public static PageRequest of(int pageNumber, int pageSize, Sort.Direction direction, String... properties) {
return of(pageNumber, pageSize, Sort.by(direction, properties));
}
...
}
PageRequest의 생성자를 보면 pageNumber, pageSize, Sort 를 받고 있습니다.
pageNumber : 페이지 번호,
pageSize : 페이지 당 데이터 수,
Sort : 정렬 기준
✔ Contorller
예를들어 localhost:8080/posts?page=1&size=5&sort=id 와 같이 요청을 보내게 되면
요청된 쿼리 스트링을 파싱하여 Pageable객체로 매핑되어지게 됩니다.
@PageableDefault : 페이지 정보가 설정되지 않았을 때 기본값을 설정합니다.
// 게시판 메인 페이지 (페이징)
@GetMapping("/board")
// @PageableDefault 속성
// @PageableDefault(page = 1, size = 10, sort="id", direction = Sort.Direction.DESC)
public String paging(@PageableDefault(page = 1, size = 5, sort="id", direction = Sort.Direction.DESC)Pageable pageable,
Model model){
Page<ResPostDto> postPage = postService.findAllPostPage(pageable);
int blockLimit = 3;
int startPage = ( ( (int) Math.ceil(((double)pageable.getPageNumber() / blockLimit))) - 1) * blockLimit + 1;
int endPage = Math.min((startPage + blockLimit - 1), postPage.getTotalPages());
model.addAttribute("postPage", postPage);
model.addAttribute("startPage", startPage);
model.addAttribute("endPage", endPage);
return "board/main";
}
페이지 네비게이션 바
blockLimit : 표시할 페이지 범위(크기) (ex : 1 2 3, 2 3 4, 4 5 6 )
startPage : 표시될 블럭의 시작 페이지
endPage : 표시될 블럭의 끝 페이지
위 3가지 연산된 값을 사용하여 페이지 네비게이션 바를 설정합니다.
🔎 타임리프 적용하기
<div class="container mt-5" >
<h2>게시판 메인</h2>
<div style="border-bottom: 1px solid #DEE2E6"></div>
<br>
<table class="table table-hover">
<thead>
<tr>
<th class="col-1 text-center" scope="col">번호</th>
<th class="col-5 text-center" scope="col">제목</th>
<th class="col-2 text-center" scope="col">작성자</th>
<th class="col-2 text-center" scope="col">작성일</th>
</tr>
</thead>
<tbody>
<tr th:each="post, postState : ${postPage}" th:onclick="|location.href='@{/post/{id}(id=${post.getId()})}'|">
<th class="text-center" th:text="${post.id}" scope="row"></th>
<td class="text-center" th:text="${post.getTitle()}"></td>
<td class="text-center" th:text="${post.getAuthor()}"></td>
<td class="text-center" th:text="${post.getPostTime()}"></td>
</tr>
</tbody>
</table>
<div style="border-bottom: 1px solid #DEE2E6"></div>
<br>
<!-- Page navigation -->
<div class="position-relative">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class="page-item">
<a th:if="${postPage.first}" class="page-link" aria-label="Previous">
<span aria-hidden="true">«</span>
</a>
<a th:unless="${postPage.first}" th:href="@{/board(page=${postPage.number})}" class="page-link" aria-label="Previous">
<span aria-hidden="true">«</span>
</a>
</li>
<li th:each="page : ${#numbers.sequence(startPage, endPage)}">
<a th:if="${page == postPage.number + 1}" class="page-link active" th:text="${page}"></a>
<a th:unless="${page == postPage.number + 1}" class="page-link" th:text="${page}" th:href="@{/board(page=${page})}"></a>
</li>
<li class="page-item">
<a th:if="${postPage.last}" class="page-link" aria-label="Next">
<span aria-hidden="true">»</span>
</a>
<a th:unless="${postPage.last}" th:href="@{/board(page=${postPage.number+2})}" class="page-link" aria-label="Next">
<span aria-hidden="true">»</span>
</a>
</li>
<li>
<!--버튼-->
<a class="btn btn-primary btn-sm position-absolute end-0" href="/post" >
게시글 등록
</a>
</li>
</ul>
</nav>
</div>
</div>
✔ Service
Controller로부터 받은 Pageable객체를 통해 페이지 정보를 다시 설정하여 Repository로 보냅니다.
Page<Post> postPages = postRepository.findAll(PageRequest.of(pageable.getPageNumber()-1, pageable.getPageSize(), pageable.getSort()));
바로 pageable객체를 넘겨주어도 되지만 PageRequest.of(pageable.getPageNumber() - 1, ...) 하는 이유는 현재 표시되어 받아오는 페이지의 값은 1이지만 실제 데이터베이스의 페이지는 0부터 시작이기 때문에 -1을 해줍니다.
또한 spring data jpa의 findAll메서드를 통해 페이징 정보를 입력받아 Page인터페이스를 통해 페이징된 데이터를 표현하고 반환합니다.
// 게시글 전체 조회(페이징)
public Page<ResPostDto> findAllPostPage(Pageable pageable){
Page<Post> postPages = postRepository.findAll(PageRequest.of(pageable.getPageNumber()-1, pageable.getPageSize(), pageable.getSort()));
Page<ResPostDto> postResDto = postPages.map(
post -> ResPostDto.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.author(post.getMember().getName())
.userId(post.getMember().getId())
.postTime(post.getModifiedDate())
.build()
);
return postResDto;
}
✔ Repository
Spring Data JPA를 통해 따로 메서드를 만들지 않아도 .findAll 메서드를 통해 페이지 정보를 받아 Page객체로 받을 수 있습니다.
package org.springframework.data.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> {
Iterable<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
}
Pageable 을 사용하여 정적으로 정의된 쿼리에 페이지 정보를 동적으로 추가하여 원하는 범위의 데이터를 얻을 수 있습니다.
메서드가 실행되었을때 요청되는 쿼리문은 아래와 같습니다.
1.
게시글을 조회하는 쿼리문이 나갑니다.
sort="id", direction = Sort.Direction.DESC 설정된 정렬정보에 의해 정렬됩니다. (order by)
page = 1, size = 5 현재 페이지와 크기를 통해 가져올 범위를 설정합니다 (limit)
2.
게시글의 전체 항목의 개수 정보를 조회하여 페이징에 사용합니다.
3.
게시판 메인을 보시면 작성자의 이름도 가져오기 때문에( member.getName() )
멤버객체의 값을 가져오는 시점에 지연로딩에 의해 멤버에대한 쿼리문도 함께 요청되어집니다.
🚨 N+1 문제 발생
게시판 메인의 작성자 부분을 보시면 총 3명의 멤버가 작성한것을 알 수 있습니다.
이렇게 되면 다른 멤버의 정보를 가져올때마다 쿼리문이 나가게 됩니다.
즉, 위의 게시판 메인페이지를 요청하면 3명의 멤버에 대한 3개의 쿼리가 나가게 됩니다.
만약 모든 게시글의 작성자가 다르고, 페이지의 사이즈가 더 크다면 더 많은 쿼리가 나가게 될것입니다.
이러한 N+1문제를 해결하기위해 fetch join을 사용할 수 있습니다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select p from Post p join fetch p.member")
Page<Post> findAllPost(Pageable pageable);
}
위 쿼리를 보시면 멤버의 데이터까지 join되어서 한번에 가져오게됩니다.
post와 member의 관계는 ManyToOne이기때문에 join을 해도 데이터가 늘어나지 않기때문에 페이징도 가능합니다.
📌 전체 코드
📚 Reference
'Project > SpringBoot+JPA 게시판' 카테고리의 다른 글
[SpringBoot + JPA 게시판 만들기] 로그인, 로그아웃 기능 구현 및 타임리프 적용 (+스프링 시큐리티) (0) | 2024.03.31 |
---|---|
[SpringBoot + JPA 게시판 만들기] 게시판CRUD 서비스 로직 작성 + 테스트 코드 추가 (0) | 2024.03.09 |
[SpringBoot + JPA 게시판 만들기] 개발 과정에서 만난 고민 builder?, Spring Data JPA?, DTO? (0) | 2024.02.19 |
[SpringBoot + JPA 게시판 만들기] 프로젝트 생성 및 환경 설정 + 엔티티(Entity) 생성 - 2 (0) | 2024.02.13 |
[SpringBoot + JPA 게시판 만들기] 요구사항 및 프로젝트 설계 - 1 (0) | 2024.02.08 |