jdbcTemplate + Spring Data로 페이지네이션 구현하기

페이지네이션은 구현하기 어렵다.

간단해보이는 기능인 페이지네이션은, 사실은 구현하기 어려운 기능이다.

  1. 보여줄 row에 맞춰 페이지 개수를 만들어 내야함
  2. 현재 페이지가 첫 페이지이거나 끝 페이지인 것을 확인해야 함
  3. 컨트롤러에서 ?page=1, ?size=10 등으로 사용자에게 정보를 받아야 함
  4. 한 블록에서 10개의 페이지씩 보여주기로 정했으면, 다음 버튼을 통해 다음 번 블록부터는 20 페이지부터 보여줘야 함

등 여러가지로 복잡한 기능이다. 하지만, Spring Data에서는 Pageable, Page와 같은 인터페이스를 제공하기 때문에 보다 쉽게 페이지네이션 구현이 가능하다. 단, 4번의 경우 직접 구현해주어야 한다.(…)

Spring Data gradle에 추가

jdbcTemplate을 사용할 것이기 때문에, spring-boot-stater-data-jdbc를 사용한다.

implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'

BookRepository

파라미터로 Pageable을 받아서, Page의 구현체인 PegeImpl을 반환핸다. 이렇게 보내면, 하나의 페이지가 반환된다.

public Page<Book> findAll(Pageable pageable) {

    // 데이터 조회: LIMIT와 OFFSET 사용
    String sql = "select * from books order by book_id limit ? offset ?";
    List<Book> books = null;
    try {
        books = template.query(sql, getBookMapper(), pageable.getPageSize(), pageable.getOffset());
    } catch (DataAccessException e) {
        books = List.of();
    }

    // 전체 레코드 수 조회
    String countSql = "select count(*) from books";
    int total = 0;
    try {
        total = template.queryForObject(countSql, Integer.class);
    } catch (DataAccessException e) {
        total = 0;
    }

    return new PageImpl<>(books, pageable, total);
}

Controller

컨트롤러에서는 Pageable을 받을 수 있는데, 유저로부터 ?page= 와 같은 정보를 추상화해준 객체이다.

단, 페이지 블록에 대한 기능은 없기 떄문에, 직접 구현해주었다.

@GetMapping("/")
public String index(Model model, Pageable pageable) {
    log.debug("pageable={}", pageable);
    Page<BookListItem> bookPage = bookService.findAll(pageable);

    int blockSize = 10; // 한 블록에 표시할 페이지 수
    int currentPage = bookPage.getNumber();
    int totalPages = bookPage.getTotalPages();

    // 현재 블록의 시작 페이지 (0부터 시작)
    int startPage = (currentPage / blockSize) * blockSize;
    // 현재 블록의 끝 페이지 (단, 전체 페이지 수를 넘지 않도록)
    int endPage = Math.min(startPage + blockSize, totalPages);

    // startPage부터 endPage까지 페이지 번호 목록 생성
    List<Integer> pageNumbers = new ArrayList<>();
    for (int i = startPage; i < endPage; i++) {
        pageNumbers.add(i);
    }

    model.addAttribute("bookPage", bookPage);
    model.addAttribute("pageNumbers", pageNumbers);
    model.addAttribute("startPage", startPage);
    model.addAttribute("endPage", endPage);
    model.addAttribute("blockSize", blockSize);

    return "home/index";
}

Thymeleaf

bookPage 에서 content로 book을 꺼낼 수 있고, model에서 계산해서 넣어준 attr들을 이용해서 페이지네이션을 구현했다.

<ul class="book-list" th:if="${bookPage.totalElements > 0}">
    <li class="book-item" th:each="book : ${bookPage.content}" th:object="${book}" th:id="|rent*{id}|">
        <div class="error book">
            <p>책 오류</p>
        </div>
        <div class="error user">
            <p>유저 오류</p>
        </div>
        <div>
            <img class="book-cover" th:src="|/images/*{coverImage.storeFileName}|"
                 th:alt="*{coverImage.uploadFileName}">
            <span th:text="*{name}">책 이름</span>
            <small class="text-muted ms-2">(ISBN: <span th:text="*{isbn}">책 isbn</span>)</small>
        </div>
        <div class="btn-container">
            <button class="btn btn-primary" th:value="*{id}" onclick="rent(this.value, `rent${this.value}`)"
                    th:text="#{button.book.rent}">대출
            </button>
            <button class="btn btn-danger" th:value="*{id}" onclick="unRent(this.value, `rent${this.value}`)"
                    th:text="#{button.book.unrent}">반납
            </button>
        </div>
    </li>
</ul>
<div>
    <nav aria-label="Page navigation">
        <ul class="pagination">
            <!-- 이전 블록 링크: 시작 페이지가 0보다 크면 표시 -->
            <li class="page-item" th:if="${startPage > 0}">
                <a class="page-link" th:href="@{/(page=${startPage - 1})}" aria-label="Previous Block">
                    <span aria-hidden="true">&laquo;</span>
                </a>
            </li>

            <!-- 페이지 번호 링크: 컨트롤러에서 전달한 pageNumbers 리스트 사용 -->
            <li class="page-item" th:each="page : ${pageNumbers}" th:classappend="${page == bookPage.number} ? 'active'">
                <a class="page-link" th:href="@{/(page=${page})}" th:text="${page + 1}">1</a>
            </li>

            <!-- 다음 블록 링크: endPage가 전체 페이지 수보다 작으면 표시 -->
            <li class="page-item" th:if="${endPage < bookPage.totalPages}">
                <a class="page-link" th:href="@{/(page=${endPage})}" aria-label="Next Block">
                    <span aria-hidden="true">&raquo;</span>
                </a>
            </li>
        </ul>
    </nav>
</div>

페이지 블록 기능 추상화

하지만, 페이지 블록 기능은 반복되는 코드가 많기 때문에 따로 추상화해주기로 한다.

public class PageBlock {
    private final int startPage;
    private final int endPage;
    private final List<Integer> pageNumbers;
    private final int currentPage;
    private final int totalPages;
}
public class PaginationUtil {
    /**
     * 주어진 Page 객체와 블록 크기를 기반으로 페이지 블록 정보를 생성합니다.
     *
     * @param page      Spring Data Page 객체
     * @param blockSize 한 블록에 표시할 페이지 수
     * @return PageBlock 객체
     */
    public static <T> PageBlock createPageBlock(Page<T> page, int blockSize) {
        int currentPage = page.getNumber();
        int totalPages = page.getTotalPages();
        int startPage = (currentPage / blockSize) * blockSize;
        int endPage = Math.min(startPage + blockSize, totalPages);

        List<Integer> pageNumbers = new ArrayList<>();
        for (int i = startPage; i < endPage; i++) {
            pageNumbers.add(i);
        }

        return new PageBlock(startPage, endPage, pageNumbers, currentPage, totalPages);

    }
}
public String index(Model model, Pageable pageable) {
    log.debug("pageable={}", pageable);
    Page<BookListItem> bookPage = bookService.findAll(pageable);

    int blockSize = 10; // 한 블록에 표시할 페이지 수
    PageBlock pageBlock = PaginationUtil.createPageBlock(bookPage, blockSize);

    model.addAttribute("bookPage", bookPage);
    model.addAttribute("pageBlock", pageBlock);

    return "home/index";
}
<nav aria-label="Page navigation">
    <ul class="pagination">
        <!-- 이전 블록 링크 -->
        <li class="page-item" th:if="${pageBlock.startPage > 0}">
            <a class="page-link" th:href="@{/(page=${pageBlock.startPage - 1})}" aria-label="Previous Block">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>

        <!-- 페이지 번호 링크 -->
        <li class="page-item" th:each="page : ${pageBlock.pageNumbers}" th:classappend="${page == pageBlock.currentPage} ? 'active'">
            <a class="page-link" th:href="@{/(page=${page})}" th:text="${page + 1}"></a>
        </li>

        <!-- 다음 블록 링크 -->
        <li class="page-item" th:if="${pageBlock.endPage < pageBlock.totalPages}">
            <a class="page-link" th:href="@{/(page=${pageBlock.endPage})}" aria-label="Next Block">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
    </ul>
</nav>

댓글

개발자  김철준

백엔드 개발자 김철준의 블로그입니다.

주요 프로젝트