Project/SpringBoot+JPA 게시판

[SpringBoot + JPA 게시판 만들기] 게시판 페이징 및 N+1문제 최적화 ( Pageable, join fetch )

장용석 2024. 4. 25. 19:08

 

🤔 페이징(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">&laquo;</span>
                    </a>
                    <a th:unless="${postPage.first}" th:href="@{/board(page=${postPage.number})}" class="page-link" aria-label="Previous">
                        <span aria-hidden="true">&laquo;</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">&raquo;</span>
                    </a>
                    <a th:unless="${postPage.last}" th:href="@{/board(page=${postPage.number+2})}" class="page-link" aria-label="Next">
                        <span aria-hidden="true">&raquo;</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을 해도 데이터가 늘어나지 않기때문에 페이징도 가능합니다.

 

📌 전체 코드

 

GitHub - Rookie8294/project-board-springboot-jpa: SpringBoot + JPA 를 활용하여 CRUD 게시판 프로젝트

SpringBoot + JPA 를 활용하여 CRUD 게시판 프로젝트. Contribute to Rookie8294/project-board-springboot-jpa development by creating an account on GitHub.

github.com

 

 

 


 

📚 Reference

https://docs.spring.io/spring-data/jpa/reference/repositories/query-methods-details.html#repositories.special-parameters