본문 바로가기
스프링과 JPA 기반 웹 어플리케이션 개발/8부 검색 및 첫 페이지

80. 페이징 적용

by Backchus 2020. 5. 17.

고전적인 방식의 페이징

  • SQL의 limit과 offset 사용하기

스프링 데이터 JPA가 제공하는 Pageable 사용하기

  • page와 size
  • sort도 지원한다.
  • 기본값 설정하는 방법 @PageableDefault

이전에 empty 컬렉션을 Model에 넣을 때 발생했던 버그(?)에 대하여...

  • 사실 버그가 아니라 스프링 MVC의 정해진 동작 방식.(이상하긴 하지만..)
  • 우회하려면 이름을 반드시 줄 것.

페이징 적용을 확인하기 위해 /study/data라는 요청을 받는 핸들러로 임시로 여러개의 스터디를 삽입 할 수 있게 코드를 생성한다.(생성 후 제거)

package me.weekbelt.studyolle.modules.study;

@RequiredArgsConstructor
@Controller
public class StudyController {


    // .........

    // 임시로 스터디 데이터를 넣어주기위한 핸들러(나중에 삭제 예정)
    @GetMapping("/study/data")
    public String generateTestData(@CurrentAccount Account account) {
        studyService.generateTestStudies(account);
        return "redirect:/";
    }
}

package me.weekbelt.studyolle.modules.study;

@RequiredArgsConstructor
@Transactional
@Service
public class StudyService {

    // ...........
    
    // 테스트 데이터를 만들기 위한 임시 로직(삭제 예정)
    public void generateTestStudies(Account account) {
        for (int i = 0; i < 30; i++) {
            String randomValue = RandomString.make(5);
            Study study = Study.builder()
                    .title("테스트 스터디 " + randomValue)
                    .path("test-" + randomValue)
                    .shortDescription("테스트용 스터디 입니다.")
                    .fullDescription("test")
                    .tags(new HashSet<>())
                    .managers(new HashSet<>())
                    .build();
            study.publish();;
            Study newStudy = this.createNewStudy(study, account);
            Tag jpa = tagRepository.findByTitle("JPA");
            newStudy.getTags().add(jpa);
        }
    }
}

 

페이징을 적용하기 위해 코드 리팩토링

package me.weekbelt.studyolle.modules.study;

@Transactional(readOnly = true)
public interface StudyRepositoryExtension {

    Page<Study> findByKeyword(String keyword, Pageable pageable);
}
package me.weekbelt.studyolle.modules.study;

public class StudyRepositoryExtensionImpl extends QuerydslRepositorySupport implements StudyRepositoryExtension{

    // .........
    
    @Override
    public Page<Study> findByKeyword(String keyword, Pageable pageable) {
        QStudy study = QStudy.study;
        JPQLQuery<Study> query = from(study).where(study.published.isTrue()
                .and(study.title.containsIgnoreCase(keyword))
                .or(study.tags.any().title.containsIgnoreCase(keyword))
                .or(study.zones.any().localNameOfCity.containsIgnoreCase(keyword)))
                .leftJoin(study.tags, QTag.tag).fetchJoin()
                .leftJoin(study.zones, QZone.zone).fetchJoin()
                .leftJoin(study.members, QAccount.account).fetchJoin()
                .distinct();
        JPQLQuery<Study> pageableQuery = getQuerydsl().applyPagination(pageable, query);
        QueryResults<Study> fetchResults = pageableQuery.fetchResults();
        return new PageImpl<>(fetchResults.getResults(), pageable, fetchResults.getTotal());
    }
}
package me.weekbelt.studyolle.modules.main;

@RequiredArgsConstructor
@Controller
public class MainController {

    // .......
    
    @GetMapping("/search/study")  // TODO: size, page, sort를 Pageable에 바인딩
    public String searchStudy(String keyword, Model model,
                              @PageableDefault(size = 9, sort = "publishedDateTime", direction = Sort.Direction.DESC)
                                      Pageable pageable) {
        Page<Study> studyPage = studyRepository.findByKeyword(keyword, pageable);
        model.addAttribute("studyPage", studyPage);
        model.addAttribute("keyword", keyword);
        return "search";
    }
}

 

뷰 리팩토링

search.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments.html :: head"></head>
<body class="bg-light">
<div th:replace="fragments.html :: main-nav"></div>
<div class="container">
    <div class="py-5 text-center">
        <p class="lead" th:if="${studyPage.getTotalElements() == 0}">
            <strong th:text="${keyword}" id="keyword" class="context"></strong>에 해당하는 스터디가 없습니다.
        </p>
        <p class="lead" th:if="${studyPage.getTotalElements() > 0}">
            <strong th:text="${keyword}" id="keyword" class="context"></strong>에 해당하는 스터디를
            <span th:text="${studyPage.getTotalElements()}"></span>개
            찾았습니다.
        </p>
        <div class="dropdown">
            <button class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                검색 결과 정렬 방식
            </button>
            <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
                <a class="dropdown-item" th:classappend="${#strings.equals(sortProperty, 'publishedDateTime')}? active"
                   th:href="@{'/search/study?sort=publishedDateTime,desc&keyword=' + ${keyword}}">
                    스터디 공개일
                </a>
                <a class="dropdown-item" th:classappend="${#strings.equals(sortProperty, 'memberCount')}? active"
                   th:href="@{'/search/study?sort=memberCount,desc&keyword=' + ${keyword}}">
                    멤버수
                </a>
            </div>
        </div>
    </div>
    <div class="row justify-content-center">
        <div th:replace="fragments.html :: study-list (studyList=${studyPage.getContent()})"></div>
    </div>
    <div class="row justify-content-center">
        <div class="col-sm-10">
            <nav>
                <ul class="pagination justify-content-center">
                    <li class="page-item" th:classappend="${!studyPage.hasPrevious()}? disabled">
                        <a th:href="@{'/search/study?keyword=' + ${keyword} + '&sort=' + ${sortProperty} + ',desc&page=' + ${studyPage.getNumber() - 1}}"
                           class="page-link" tabindex="-1" aria-disabled="true">
                            Previous
                        </a>
                    </li>
                    <li class="page-item" th:classappend="${i == studyPage.getNumber()}? active"
                        th:each="i: ${#numbers.sequence(0, studyPage.getTotalPages() - 1)}">
                        <a th:href="@{'/search/study?keyword=' + ${keyword} + '&sort=' + ${sortProperty} + ',desc&page=' + ${i}}"
                           class="page-link" href="#" th:text="${i + 1}">1</a>
                    </li>
                    <li class="page-item" th:classappend="${!studyPage.hasNext()}? disabled">
                        <a th:href="@{'/search/study?keyword=' + ${keyword} + '&sort=' + ${sortProperty} + ',desc&page=' + ${studyPage.getNumber() + 1}}"
                           class="page-link">
                            Next
                        </a>
                    </li>
                </ul>
            </nav>
        </div>
    </div>
</div>
<div th:replace="fragments.html :: footer"></div>
<script th:replace="fragments.html :: date-time"></script>
<script src="/node_modules/mark.js/dist/jquery.mark.min.js"></script>
<script type="application/javascript">
    $(function(){
        var mark = function() {
            // Read the keyword
            var keyword = $("#keyword").text();

            // Determine selected options
            var options = {
                "each": function(element) {
                    setTimeout(function() {
                        $(element).addClass("animate");
                    }, 150);
                }
            };

            // Mark the keyword inside the context
            $(".context").unmark({
                done: function() {
                    $(".context").mark(keyword, options);
                }
            });
        };

        mark();
    });
</script>
</body>
</html>

 

fragments.html에 study-list fragment추가

<div th:fragment="study-list (studyList)" class="col-sm-12">
    <div class="row">
        <div class="col-md-4" th:each="study: ${studyList}">
            <div class="card mb-4 shadow-sm">
                <img th:src="${study.image}" class="card-img-top" th:alt="${study.title}" >
                <div class="card-body">
                    <a th:href="@{'/study/' + ${study.path}}" class="text-decoration-none">
                        <h5 class="card-title context" th:text="${study.title}"></h5>
                    </a>
                    <p class="card-text" th:text="${study.shortDescription}">Short description</p>
                    <p class="card-text context">
                                <span th:each="tag: ${study.tags}" class="font-weight-light text-monospace badge badge-pill badge-info mr-3">
                                    <a th:href="@{'/search/tag/' + ${tag.title}}" class="text-decoration-none text-white">
                                        <i class="fa fa-tag"></i> <span th:text="${tag.title}">Tag</span>
                                    </a>
                                </span>
                        <span th:each="zone: ${study.zones}" class="font-weight-light text-monospace badge badge-primary mr-3">
                                    <a th:href="@{'/search/zone/' + ${zone.id}}" class="text-decoration-none text-white">
                                        <i class="fa fa-globe"></i> <span th:text="${zone.localNameOfCity}" class="text-white">City</span>
                                    </a>
                                </span>
                    </p>
                    <div class="d-flex justify-content-between align-items-center">
                        <small class="text-muted">
                            <i class="fa fa-user-circle"></i>
                            <span th:text="${study.memberCount}"></span>명
                        </small>
                        <small class="text-muted date" th:text="${study.publishedDateTime}">9 mins</small>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

 

 

참고: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-JPA-%EC%9B%B9%EC%95%B1#

 

스프링과 JPA 기반 웹 애플리케이션 개발 - 인프런

이 강좌에서 여러분은 실제로 운영 중인 서비스를 스프링, JPA 그리고 타임리프를 비롯한 여러 자바 기반의 여러 오픈 소스 기술을 사용하여 웹 애플리케이션을 개발하는 과정을 학습할 수 있습�

www.inflearn.com