스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

reBorn.B 2022. 8. 16. 00:07

스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

참고 링크 : https://wikidocs.net/book/7601

오라클DB와 intelliJ로 작업하였습니다


SBB의 질문 목록은 현재 페이징 처리가 안되기 때문에 게시물 300개를 작성하면 한 페이지에 300개의 게시물이 모두 조회된다. 이번 장에서는 페이징(Paging)을 적용하여 이 문제를 해결해 보자.

만약 로컬서버가 실행 중이라면 로컬 서버를 중지하고 [Run -> Run As -> Junit Test]로 testJpa 메서드를 실행하자. 그리고 다시 로컬서버를 실행한 후에 브라우저에서 질문 목록을 확인해 보자.

package com.gosari.repick_project;

import com.gosari.repick_project.question.QuestionService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@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개의 테스트 데이터를 생성하는 테스트 케이스를 작성했다.

3자리수를 표현한다고 하면, 001,002,010 뭐 이런식으로 표현하고 싶을때가 있다. (일련번호등을 표시할때 )
"%03d"처럼 세자리일 경우는 3, 네자리일 경우는 4로 표기하면 원하는 방법으로 나온다.

1. 만약 로컬서버가 실행 중이라면 로컬 서버를 중지하고

2.[Run -> Run As -> Junit Test]로 testJpa 메서드를 실행하자.

3.그리고 다시 로컬서버를 실행한 후에 브라우저에서 질문 목록을 확인해 보자.

스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing
스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

테스트 케이스로 등록한 데이터가 보일 것이다. 그리고 300개 이상의 데이터가 한 페이지 보여지는 것을 확인할 수 있다. 이러한 이유로 페이징은 반드시 필요하다.

그리고 등록한 게시물도 최근 순으로 보여야 하는데 등록한 순서로 보이는 문제도 있다. 이 문제도 함께 해결해 보자.


페이징 구현하기

페이징을 구현하기 위해 추가로 설치해야 하는 라이브러리는 없다. JPA 환경 구축시 설치했던 JPA 관련 라이브러리에 이미 페이징을 위한 패키지들이 들어있기 때문이다.

다음의 클래스들을 이용하면 페이징을 쉽게 구현할 수 있다.

  • org.springframework.data.domain.Page
  • org.springframework.data.domain.PageRequest
  • org.springframework.data.domain.Pageable

위에 소개한 클래스를 적용해 페이징을 구현해 보자. 먼저 Question 리포지터리에 다음과 같은 findAll 메서드를 추가하자.

package com.gosari.repick_project.question;

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

import java.util.List;

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 메서드를 생성했다.

그리고 Question 서비스도 다음과 같이 수정하자.

package com.gosari.repick_project.question;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.List;
import java.util.Optional;
import com.gosari.repick_project.Exception.DataNotFoundException;

@RequiredArgsConstructor //QuestionRepository 생성자 생성해줌
@Service
public class QuestionService {
    private final QuestionRepository questionRepository;

    public List<Question> getList(){
        return this.questionRepository.findAll();
    }

    /*Question 데이터 조사*/
    public Question getQuestion(Integer id){
        Optional<Question> question = this.questionRepository.findById(id);
        if(question.isPresent()){
            return question.get();
        }else{
            throw new DataNotFoundException("question not found");
        }
    }
    
    /*페이징*/
    public Page<Question> getList(int page){
        Pageable pageable = PageRequest.of(page, 10);
        return this.questionRepository.findAll(pageable);
    }
    
    /*질문데이터를 저장하는 create메서드*/
    public void create(String subject, String content){
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(new Date()); //.now()가 아닌 new Date()로 해주면 된다
        this.questionRepository.save(q);
    }

}

질문 목록을 조회하는 getList 메서드를 위와 같이 변경했다.

getList 메서드는 정수 타입의 페이지번호를 입력받아 해당 페이지의 질문 목록을 리턴하는 메서드로 변경했다.

Pageable 객체를 생성할때 사용한 PageRequest.of(page, 10)에서

page는 조회할 페이지의 번호이고 10은 한 페이지에 보여줄 게시물의 갯수를 의미한다.

이렇게 하면 데이터 전체를 조회하지 않고 해당 페이지의 데이터만 조회하도록 쿼리가 변경된다.

Question 서비스의 getList 메서드의 입출력 구조가 변경되었으므로

Question 컨트롤러도 다음과 같이 수정해야 한다.

package com.gosari.repick_project.question;

import com.gosari.repick_project.answer.AnswerForm;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;


import javax.validation.Valid;
import java.util.List;


@RequestMapping("/question") //URL프리픽스(prefix)
@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionService questionService;
    /*질문리스트*/
    @RequestMapping("/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";
    }

    /*질문상세*/
    @RequestMapping(value = "/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id,
                         AnswerForm answerForm) { /*AnswerForm추가*/
        Question question = this.questionService.getQuestion(id);
        model.addAttribute("question", question);
        return "question_detail";
    }

    /*질문등록하기*/
    @GetMapping("/create") /*매개변수로 QuestionForm 객체를 추가*/
    public String questionCreate(QuestionForm questionForm) {
        return "question_form";
    }

    /*질문등록하기*/
    @PostMapping("/create") /*QuestionForm 사용할수있게 수정*/
    public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "question_form";
        }
        this.questionService.create(questionForm.getSubject(), questionForm.getContent());
        return "redirect:/question/list";
    }

}

http://localhost:8080/question/list?page=0 처럼

GET 방식으로 요청된 URL에서 page값을 가져오기 위해 

@RequestParam(value="page", defaultValue="0")   int page 매개변수가 list 메서드에 추가되었다.

URL에 페이지 파라미터 page가 전달되지 않은 경우 디폴트 값으로 0이 되도록 설정했다.

스프링부트의 페이징은 첫페이지 번호가 1이 아닌 0이다.

그리고 템플릿에 Page<Question> 객체인 paging을 전달했다.

Page 객체에는 다음과 같은 속성이 있다. 다음의 속성들은 템플릿에서 페이징을 처리할 때 사용할 것이다.

paging.isEmpty 페이지 존재 여부 (게시물이 있으면 false, 없으면 true)
paging.totalElements 전체 게시물 개수
paging.totalPages 전체 페이지 개수
paging.size 페이지당 보여줄 게시물 개수
paging.number 현재 페이지 번호
paging.hasPrevious 이전 페이지 존재 여부
paging.hasNext 다음 페이지 존재 여부

그리고 기존에 전달했던 이름인 "questionList" 대신 "paging" 이름으로 템플릿에 전달했기 때문에

question_list.html 템플릿도 다음과 같이 변경해야 한다.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      layout:decorate="~{layout}">
<!--layout:decorate 속성 : 템플릿의 레이아웃(부모템플릿)으로 사용할 템플릿을 설정-->


<div layout:fragment="content" class="container my-3"> <!--<th:block layout:fragment="content"></th:block>-->
    <table class="table">
        <thead class="table-dark">
        <tr>
            <th>번호</th>
            <th>제목</th>
            <th>작성일시</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="question, loop : ${paging}"> <!--수정-->
            <td th:text="${loop.count}"></td><!--테이블항목에 번호추가-->
            <td>
                <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
            </td>
            <td th:text="${#dates.format(question.createDate, 'yyyy-MM-dd')}"></td> <!--날짜객체 날짜포맷에맞게 변환-->
        </tr>
        </tbody>
    </table>

    <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a> <!--질문버튼추가 부트스트랩으로 버튼디자인!-->

</div>


</html>

이렇게 수정하고 브라우저에서 http://localhost:8080/question/list?page=0 URL을 요청해 보자.

다음과 같이 첫페이지에 해당하는 게시물 10개만 조회되는 것을 확인할 수 있다.

스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

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

스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

템플릿에 페이지 이동 기능 구현하기

질문 목록에서 페이지를 이동하려면 페이지를 이동할 수 있는 "이전", "다음" 과 같은 링크가 필요하다.

이번에는 질문 목록에 페이지를 이동할 수 있는 링크들을 추가해 보자.

question_list.html템플릿 파일의 </table> 태그 바로 밑에 다음 코드를 추가하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      layout:decorate="~{layout}">
<!--layout:decorate 속성 : 템플릿의 레이아웃(부모템플릿)으로 사용할 템플릿을 설정-->


<div layout:fragment="content" class="container my-3"> <!--<th:block layout:fragment="content"></th:block>-->
    <table class="table">
        <thead class="table-dark">
        <tr>
            <th>번호</th>
            <th>제목</th>
            <th>작성일시</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="question, loop : ${paging}">
            <td th:text="${loop.count}"></td><!--테이블항목에 번호추가-->
            <td>
                <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
            </td>
            <td th:text="${#dates.format(question.createDate, 'yyyy-MM-dd')}"></td> <!--날짜객체 날짜포맷에맞게 변환-->
        </tr>
        </tbody>
    </table>

    <!-- 페이징처리 시작 -->
    <div th:if="${!paging.isEmpty()}"><!--paging.isEmpty:페이지존재여부(게시물있으면 false,없으면 true)-->
        <ul class="pagination justify-content-center">
            <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
            <!-- !paging.hasPrevious - disabled : 이전페이지가 없으면 비활성화-->

                <a class="page-link" th:href="@{|?page=${paging.number-1}|}">
                <!--이전페이지 링크-->
                    <span>이전</span>
                </a>
            </li>


            <!--th:each :페이지 리스트 루프--> <!--#numbers.sequence(시작, 끝)-->
            <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
            th:classappend="${page == paging.number} ? 'active'"
            class="page-item">
            <!--page == paging.number : 현재페이지와 같으면 active 적용-->

                <a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
            </li>


            <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
            <!-- !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코드가 추가되었지만 어렵지 않으니 찬찬히 살펴보자.

이전 페이지가 없는 경우에는 "이전" 링크가 비활성화(disabled)되도록 하였다.

(다음페이지의 경우도 마찬가지 방법으로 적용했다.)

그리고 페이지 리스트를 루프 돌면서 해당 페이지로 이동할 수 있는 링크를 생성하였다.

이때 루프 도중의 페이지가 현재 페이지와 같을 경우에는 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)}"
현재 페이지와 같으면 active 적용 th:classappend="${page == paging.number} ? 'active'"

#numbers.sequence(시작, 끝) 시작 번호부터 끝 번호까지의 루프를 만들어 내는 타임리프의 유틸리티이다.

그리고 페이지 리스트를 보기 좋게 표시하기 위해 부트스트랩의 pagination 컴포넌트를 이용하였다.

템플릿에 사용한 pagination, page-item, page-link 등이 부트스트랩 pagination 컴포넌트의 클래스이다.

부트스트랩 pagination - https://getbootstrap.com/docs/5.1/components/pagination/

페이지 리스트

여기까지 수정하고 질문 목록을 조회해 보자.

스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

페이징 처리는 잘 되었지만 한 가지 문제를 발견할 수 있다.

문제는 위에서 보듯이 이동할 수 있는 페이지가 모두 표시된다는 점이다.

이 문제를 해결하기 위해 다음과 같이 질문 목록 템플릿을 수정하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      layout:decorate="~{layout}">
<!--layout:decorate 속성 : 템플릿의 레이아웃(부모템플릿)으로 사용할 템플릿을 설정-->


<div layout:fragment="content" class="container my-3"> <!--<th:block layout:fragment="content"></th:block>-->
    <table class="table">
        <thead class="table-dark">
        <tr>
            <th>번호</th>
            <th>제목</th>
            <th>작성일시</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="question, loop : ${paging}">
            <td th:text="${loop.count}"></td><!--테이블항목에 번호추가-->
            <td>
                <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
            </td>
            <td th:text="${#dates.format(question.createDate, 'yyyy-MM-dd')}"></td> <!--날짜객체 날짜포맷에맞게 변환-->
        </tr>
        </tbody>
    </table>

    <!-- 페이징처리 시작 -->
    <div th:if="${!paging.isEmpty()}"><!--paging.isEmpty:페이지존재여부(게시물있으면 false,없으면 true)-->
        <ul class="pagination justify-content-center">
            <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
            <!-- !paging.hasPrevious - disabled : 이전페이지가 없으면 비활성화-->

                <a class="page-link" th:href="@{|?page=${paging.number-1}|}">
                <!--이전페이지 링크-->
                    <span>이전</span>
                </a>
            </li>


            <!--th:each :페이지 리스트 루프--> <!--#numbers.sequence(시작, 끝)-->
            <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"

            th:if="${page >= paging.number-5 and page <= paing.number+5}"

            th:classappend="${page == paging.number} ? 'active'"
            class="page-item">
            <!--page == paging.number : 현재페이지와 같으면 active 적용-->

                <a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
            </li>


            <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
            <!-- !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>

다음의 코드를 삽입하여 페이지 표시 제한 기능을 구현했다.

th:if="${page >= paging.number-5 and page <= paging.number+5}"

이 코드는 페이지 리스트가 현재 페이지 기준으로 좌우 5개씩 보이도록 만든다.

루프내에 표시되는 페이지가 현재 페이지를 의미하는 paging.number 보다 5만큼 작거나 큰 경우에만 표시되도록 한 것이다. 만약 현재 페이지가 15라면 다음처럼 페이지 리스트가 표시될 것이다.

스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

작성일시 역순으로 조회하기

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

package com.gosari.repick_project.question;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import com.gosari.repick_project.Exception.DataNotFoundException;

@RequiredArgsConstructor //QuestionRepository 생성자 생성해줌
@Service
public class QuestionService {
    private final QuestionRepository questionRepository;

    public List<Question> getList(){
        return this.questionRepository.findAll();
    }

    /*Question 데이터 조사*/
    public Question getQuestion(Integer id){
        Optional<Question> question = this.questionRepository.findById(id);
        if(question.isPresent()){
            return question.get();
        }else{
            throw new DataNotFoundException("question not found");
        }
    }

    /*페이징*/
    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);
    }

    /*질문데이터를 저장하는 create메서드*/
    public void create(String subject, String content){
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(new Date()); //.now()가 아닌 new Date()로 해주면 된다
        this.questionRepository.save(q);
    }

}

게시물을 역순으로 조회하기 위해서는 위와 같이 PageRequest.of 메서드의 세번째 파라미터로 Sort 객체를 전달해야 한다. Sort.Order객체로 구성된 리스트에Sort.Order객체를 추가하고 Sort.by(소트리스트)로 소트 객체를 생성할 수 있다.

작성일시(createDate)를 역순(Desc)으로 조회하려면 Sort.Order.desc("createDate") 같이 작성한다.

만약 작성일시 외에 추가로 정렬조건이 필요할 경우에는 sorts 리스트에 추가하면 된다.

이렇게 수정하고 첫번째 페이지를 조회하면 이제 가장 최근에 등록된 순으로 게시물이 출력되는 것을 확인한 수 있다.

스프링부트 게시판 페이징 - seupeulingbuteu gesipan peijing

축하한다! 페이징 기능이 완성되었다. 페이징은 사실 구현하기 까다로운 기술이다. 페이징 클래스의 도움이 없었다면 아마 이렇게 쉽게 해내기는 힘들었을 것이다.

지금까지 만든 페이징 기능에 '처음'과 '마지막' 링크를 추가하고 '…' 생략 기호까지 추가하면 더 완성도 높은 페이징 기능이 될 것이다.