[스프링부트 시리즈21] 페이징 기능 추가하기

Featured image

페이징 기능 추가하기


SBB의 질문 목록은 현재 페이징 기능이 없어 게시물을 300개 작성하면 한 페이지에 300개의 게시물이 모두 조회된다. 이 경우 한 화면에 표시할 게시물이 많아져서 스크롤바를 내려야 하는 불편함이 생긴다. 이를 해결하기 위해 질문 목록 화면에 페이징 기능을 적용해 보자. 여기서 페이징(paging)이란 입력된 정보나 데이터를 여러 페이지에 나눠 표시하고, 사용자가 페이지를 이동할 수 있게 하는 기능을 말한다.

대량 테스트 데이터 만들기


페이징을 구현하기 전에 페이징을 테스트할 수 있을 정도로 충분한 테스트 데이터를 만들어 보자. 대량의 테스트 데이터를 만드는 가장 간단한 방법은 2-05절에서 살펴본 스프링 부트의 테스트 프레임워크를 이용하는 것이다. 1) 테스트 케이스를 작성하기 위해 SbbApplicationTests.java 파일을 수정해 보자.

[파일명:SbbApplicationTests.java]

package com.mysite.sbb;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import com.mysite.sbb.question.QuestionService;

@SpringBootTest
class SbbApplicationTests {

    @Autowired
    private QuestionService questionService;

    @Test
    void testJpa() {
        for (int i = 1; i <= 300; i++) {
            String subject = String.format("테스트 데이터입니다:[%03d]", i);
            String content = "내용무";
            this.questionService.create(subject, content);
        }
    }
}

이와 같이 총 300개의 테스트 데이터를 생성하는 테스트 케이스를 작성했다.

2) 로컬 서버를 중지하고 Ctrl + Shift + F10testJpa 메서드를 실행하자. 그리고 다시 로컬 서버를 실행한 후, 브라우저에서 질문 목록 페이지를 요청해 보자. testjpa 이와 같이 테스트 케이스로 등록한 데이터가 보일 것이다. 그리고 300개 이상의 데이터가 한 페이지 보여지는 것을 확인할 수 있다. 300개가 넘는 데이터를 확인하려면 계속 스크롤을 내려야 한다. 이러한 불편함을 해결하기 위해 이어서 페이징 기능을 구현하고, 등록한 게시물이 최신순으로 보여지는 기능까지 추가해 보자.

페이징 구현하기


페이징을 구현하기 위해 추가로 설치해야 하는 라이브러리는 없다. JPA 환경 구축 시 설치했던 JPA 관련 라이브러리에 이미 페이징을 위한 패키지들이 들어 있기 때문이다. 그러므로 다음 클래스들을 이용하면 페이징을 쉽게 구현할 수 있다.

1) 위에 소개한 3가지 클래스를 사용하여 페이징을 구현해 보자. 먼저 QuestionRepository에 다음과 같이 페이징을 구현하기 위한 클래스들을 import 한 후, findAll 메서드를 추가해 보자.

[파일명:/question/QuestionRepository.java]

package com.mysite.sbb.question;

import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface QuestionRepository extends JpaRepository<Question, Integer> {
    Question findBySubject(String subject);
    Question findBySubjectAndContent(String subject, String content);
    List<Question> findBySubjectLike(String subject);
    Page<Question> findAll(Pageable pageable);
}

Pageable 객체를 입력받아 Page<Question> 타입 객체를 리턴하는 findAll 메서드를 생성했다.

2) 이번에는 QuestionService도 다음과 같이 수정해 보자.

[파일명:/question/QuestionService.java]

(... 생략 ...)
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public Page<Question> getList(int page) {
        Pageable pageable = PageRequest.of(page, 10);
        return this.questionRepository.findAll(pageable);
    }

    (... 생략 ...)
}

import 문은 Ctrl + Alt + O 키를 누르면 한번에 정리되므로 어디에 입력할지 고민하지 않아도 된다.

질문 목록을 조회하는 getList 메서드를 이와 같이 변경했다. getList 메서드는 정수 타입의 페이지 번호를 입력받아 해당 페이지의 Page 객체를 리턴하도록 변경했다. Pageable 객체를 생성할 때 사용한 PageRequest.of(page, 10)에서 page는 조회할 페이지의 번호이고 10은 한 페이지에 보여 줄 게시물의 개수를 의미한다. 이렇게 하면 데이터 전체를 조회하지 않고 해당 페이지의 데이터만 조회하도록 쿼리가 변경된다.

3) QuestionServicegetList 메서드 입출력 구조가 변경되었으므로 QuestionController도 다음과 같이 수정해야 한다.

[파일명: /question/QuestionController.java]

package com.mysite.sbb.question;

(... 생략 ...)
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.data.domain.Page;
(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @GetMapping("/list")
    public String list(Model model, @RequestParam(value="page", defaultValue="0") int page) {
        Page<Question> paging = this.questionService.getList(page);
        model.addAttribute("paging", paging);
        return "question_list";
    }

    (... 생략 ...)
}

http://localhost:8080/question/list?page=0와 같이 GET 방식으로 요청된 URL에서 page값을 가져오기 위해 list 메서드의 매개변수로 @RequestParam(value="page", defaultValue="0") int page가 추가되었다. URL에 매개변수로 page가 전달되지 않은 경우 기본값은 0이 되도록 설정했다.

  • 스프링 부트(Spring Boot)의 페이징 기능을 구현할 때 첫 페이지 번호는 1이 아닌 0이므로 기본값으로 0을 설정해야 한다.
  • GET 방식에서는 값을 전달하기 위해서 ?& 기호를 사용한다. 첫 번째 파라미터는 ? 기호를 사용하고 그 이후 추가되는 값은 & 기호를 사용한다.
속성 설명
paging.isEmpty 페이지 존재여부를 의미(게시물 존재 시 false, 부재시 true)
paging.totalElements 전체 게시물 개수를 의미
paging.totalPages 전체 페이지 개수를 의미
paging.size 페이지당 보여 줄 게시물 개수를 의미
paging.number 현재 페이지 번호를 의미
paging.hasPrevious 이전 페이지의 존재 여부 의미
`paging.hasNext 다음 페이지의 존재 여부 의미

4) 컨트롤러에서 model 객체에 기존에 전달했던 이름인 ‘questionList’ 대신 ‘paging’으로 전달하기 때문에 질문 목록 템플릿(question_list.html)을 다음과 같이 변경해야 한다.

[파일명:/templates/question_list.html]

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <table class="table">
        (... 생략 ...)
        <tbody>
            <tr th:each="question, loop : ${paging}">
                (... 생략 ...)
            </tr>
        </tbody>
    </table>
    <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>

5) 수정한 후, 브라우저에서 http://localhost:8080/question/list?page=0이라는 URL을 요청해 보자. 다음과 같이 첫 페이지에 해당하는 게시물 10개만 조회되는 것을 확인할 수 있다. 페이지0

6) 이번에는 http://localhost:8080/question/list?page=1과 같이 URL을 요청하면 다음과 같이 두 번째 페이지에 해당하는 게시물들이 조회된다. 페이지1

페이지 이동기능 추가하기


질문 목록에서 페이지를 이동하려면 페이지를 이동할 수 있는 ‘이전’, ‘다음’과 같은 링크가 필요하다. 이번에는 질문 목록 화면에서 페이지를 이동할 수 있는 링크를 추가해 보자.

1) question_list.html</table>태그 바로 밑에 다음 코드를 작성해 보자.

[파일명:/templates/question_list.html]

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <table class="table">
        (... 생략 ...)
    </table>
    <!-- 페이징처리 시작 -->
    <div th:if="${!paging.isEmpty()}">
        <ul class="pagination justify-content-center">
            <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
                <a class="page-link"
                    th:href="@{|?page=${paging.number-1}|}">
                    <span>이전</span>
                </a>
            </li>
            <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
                th:classappend="${page == paging.number} ? 'active'" 
                class="page-item">
                <a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
            </li>
            <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
                <a class="page-link" th:href="@{|?page=${paging.number+1}|}">
                    <span>다음</span>
                </a>
            </li>
        </ul>
    </div>
    <!-- 페이징처리 끝 -->
    <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>

상당히 많은 양의 HTML 코드가 추가되었지만 어렵지 않으니 찬찬히 살펴보자. 페이지 리스트를 보기 좋게 표시하기 위해 부트스트랩의 pagination 컴포넌트를 이용했다. 이 템플릿에 사용한 pagination, page-item, page-link 등이 pagination 컴포넌트의 클래스로, pagination은 ul 요소 안에 있는 내용을 꾸밀 수 있고, page-item은 각 페이지 번호나 ‘이전’, ‘다음’ 버튼을 나타내도록 하고, page-link는 ‘이전’, ‘다음’ 버튼에 링크를 나타낸다.

부트스트랩의 pagination을 자세히 알고 싶다면 https://getbootstrap.com/docs/5.3/components/pagination/를 참고하자.

이전 페이지가 없는 경우에는 ‘이전’ 링크가 비활성화(disabled)되도록 했다. ‘다음’ 링크의 경우도 마찬가지 방법으로 적용했다. 그리고 th:each 속성을 사용해 전체 페이지 수만큼 반복하면서 해당 페이지로 이동할 수 있는 ‘이전’, ‘다음’ 링크를 생성했다. 이때 반복하던 도중 요청 페이지가 현재 페이지와 같을 경우에는 active 클래스를 적용하여 페이지 링크에 파란색 배경이 나타나도록 했다.

타임리프의 th:classappend=”조건식 ? 클래스_값”은 조건식이 참인 경우 ‘클래스_값’을 class 속성에 추가한다.

위 템플릿에 사용한 주요 페이징 기능을 표로 정리해 보았다. 페이징 기능 관련 주요 코드 | 설명 —–|—– th:classappend="${!paging.hasPrevious}?'disabled'" | 이전 페이지가 없는 경우 ‘이전’ 링크를 비활성 th:classappend="${!paging.hasNext}?'disabled'" | 다음 페이지가 없으면 다음 링크를 비활성. th:href="@{|?page=${paging.number-1}|}" | 이전 페이지 링크 생성 th:href="@{|?page=${paging.number+1}|}" | 다음 페이지 링크 생성 th:each="page: ${#numbers.sequence(0,paging.totalPages-1)}" | 0부터 전체 페이지 수 만큼 이 요소를 반복해 생성한다. 이 때 현재 순번을 page 변수에 대입한다. th:classappend="${page == paging.nubmer} ?'active'" | 반복 구간 내 해당 페이지가 현재 페이지와 같은 경우 active 클래스를 적용한다.

한 가지 더 설명하면, #numbers.sequence(시작 번호, 끝 번호)는 시작 번호부터 끝 번호까지 정해진 범위만큼 반복을 만들어 내는 타임리프의 기능이다.

2) 여기까지 수정한 후, 다시 질문 목록 URL을 조회해 보자. URL조회

페이지 이동 기능은 구현했지만 화면에서 보듯이 이동할 수 있는 페이지가 모두 표시되는 문제가 발생했다.

페이지 이동 기능 완성하기


1) 앞서 발생한 문제를 해결하기 위해 다음과 같이 질문 목록 템플릿에 코드를 추가해 보자.

[파일명:/templates/question_list.html]

(... 생략 ...)
    <!-- 페이징처리 시작 -->
    <div th:if="${!paging.isEmpty()}">
        <ul class="pagination justify-content-center">
            <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
                <a class="page-link"
                    th:href="@{|?page=${paging.number-1}|}">
                    <span>이전</span>
                </a>
            </li>
            <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}" 
                th:if="${page >= paging.number-5 and page <= paging.number+5}"
                th:classappend="${page == paging.number} ? 'active'" 
                class="page-item">
                <a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
            </li>
            <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
                <a class="page-link" th:href="@{|?page=${paging.number+1}|}">
                    <span>다음</span>
                </a>
            </li>
        </ul>
    </div>
    <!-- 페이징처리 끝 -->
    <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>

이와 같이 한 줄의 코드를 삽입하여 페이지 표시 제한 기능을 구현했다. 이 코드는 현재 페이지 기준으로 좌우 5개씩 페이지 번호가 표시되도록 만든다. 즉, 반복문 내에서 표시되는 페이지가 현재 페이지를 의미하는 paging.number보다 5만큼 작거나 큰 경우에만 표시되도록 한 것이다.

2) 만약 현재 페이지가 15페이지라면 다음과 같이 페이지 번호가 표시될 것이다. 페이지 이와 같이 15페이지보다 5만큼 작은 10페이지부터 5만큼 큰 20페이지까지만 표시된다.

최신순으로 데이터 조회하기


1) 현재 질문 목록은 등록한 순서대로 데이터가 표시된다. 하지만 대부분의 게시판 서비스는 최근에 작성한 게시물이 가장 위에 보이는 것이 일반적이다. 이를 구현하기 위해 Question Service를 다음과 같이 수정해 보자.

[파일명:/question/QuestionService.java]

(... 생략 ...)
import java.util.ArrayList;
import java.util.List;
import org.springframework.data.domain.Sort;
(... 생략 ...)
public class QuestionService {

   (... 생략 ...)

    public Page<Question> getList(int page) {
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
        return this.questionRepository.findAll(pageable);
    }

    (... 생략 ...)
}

시물을 역순(최신순)으로 조회하려면 이와 같이 PageRequest.of 메서드의 세 번째 매개변수에 Sort 객체를 전달해야 한다. 작성 일시(createDate)를 역순(Desc)으로 조회하려면 Sort.Order.desc("createDate")와 같이 작성한다.

  • 만약 작성 일시 외에 정렬 조건을 추가하고 싶다면 sort.add 메서드를 활용해 sorts 리스트에 추가하면 된다.
  • 여기서 쓰인 desc는 내림차순을 의미하고, asc는 오름차순을 의미한다.

2) 수정한 뒤, 첫 번째 페이지를 조회하면 가장 최근에 등록한 순서대로 게시물이 출력되는 것을 확인할 수 있다.

게시물출력