프로젝트/Recipository

[Dev] 23.02.20. 게시글 목록 페이징 처리 (Pagination)

규글 2023. 2. 20. 05:31

 아마 이번 페이징 작업이 당분간의 마지막 작업이 될 것 같다. 처음 계획에는 카테고리 별로도 구분하는 것도 있었으나, 그것은 게시글 목록에 대한 페이징 처리 이후 조금씩 작업하기로 결정했다.

 

 현재 게시글 목록은 따로 페이징 처리가 되어있지 않은 채로 모든 게시글의 목록을 불러오고 있다. 하지만 필자는 몇 개씩을 그룹으로 묶어 페이징 처리를 하고 싶다. 이를 위해 JPA 강의 중에 간단한 Paging 방법을 소개하는 것까지 살펴보고 작업을 시작했다.

 아이디어는 간단하다. 페이징 UI를 가장 먼저 만들고, 그에 대한 요청을 GET 방식으로 주어서 게시글 중에 N번째 부터 몇 개씩만을 조회하도록 하는 것이다. 늘 그래왔듯 이번에도 알 수 없는 오류에 직면하거나 필자가 생각하지 못한 방향으로 흘러갈 수는 있겠으나, 일단 부딪혀보겠다. 말은 이렇게 했지만 작업한 뒤 동작은 원하는대로 이루어질 것이나, 이전처럼 동작하는 query로 인해서 필자가 다시 직접 query를 입력해야 할 것 같은 것은 기분 탓일까?

 

 

작업

index.html

  <div th:replace="fragments/pagination :: pagination"></div>

 가장 먼저 한 작업은 client에서 보이는 pagination을 구성한 것이다. 사용자의 마이 페이지에서도 작성한 게시글의 목록을 볼 수 있는데, 그곳에서도 pagination을 다시 활용할 수 있도록 fragment로 작성했다.

 

pagination.html

<!DOCTYPE html>
<html lang="en"
      xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<div class="d-flex justify-content-center" th:fragment="pagination">
    <ul class="pagination">
        <li class="page-item">
            <a class="page-link" th:if="${pagination.startPageNum == 1}">Prev</a>
            <a th:if="${pagination.startPageNum != 1}"
               class="page-link" th:href="|/page/${pagination.startPageNum - 1}|">Prev</a>
        </li>
        <li class="page-item" th:each="page : ${#numbers.sequence(pagination.startPageNum, pagination.endPageNum, 1)}">
            <a class="page-link" th:if="${page != pagination.pageNum}"
               th:href="|/page/${page}|" th:text="${page}"></a>
            <a class="page-link" th:if="${page == pagination.pageNum}"
               th:text="${page}"></a>
        </li>
        <li class="page-item">
            <a th:if="${pagination.endPageNum < pagination.totalPageNum}"
               class="page-link" th:href="|/page/${pagination.endPageNum + 1}|">Next</a>
            <a class="page-link" th:if="${pagination.endPageNum >= pagination.totalPageNum}">Next</a>
        </li>
    </ul>
</div>

 재사용을 위해 fragment로 작성한 부분이다. 따로 특별하게 연관된 javascript를 작성한 것은 없으며, anchor의 href 속성에 페이지를 이동해야 할 path variable 을 thymeleaf를 활용하여 작성해주었다. Pagination의 덩어리를 옮겨다닐 수 있는 Prev(이전) 과 Next(이후)는 상황에 맞게 등장할 수 있도록 thymeleaf의 if를 활용했다.

 이 부분에서 필자는 path variable이 아닌 request parameter를 활용하려고 했으나, url의 접근 허용 방식에서 어려움을 느끼고 변경했다. 지금 생각해보니 /page?number=xxx 의 방식의 url를 채택하고, /page 에 대한 url 접근을 허용하는 방식이었으면 괜찮았을 것 같다.

 

PageController.java

    // index page + 출력할 게시글 목록
    @GetMapping(value = {"/"})
    public ModelAndView main(){
        ModelAndView mView = new ModelAndView();

        Map<String, Object> map = recipeService.getRecipeList(1);

        mView.addObject("recipeList", map.get("recipeDtoList"));
        mView.addObject("pagination", map.get("pageDto"));
        mView.setViewName("index");

        return mView;
    }
    
    -------------------------------------------------------------------------
    
    // paging 처리
    @GetMapping("/page/{pageNum}")
    public ModelAndView mainWithPaging(@PathVariable("pageNum") int pageNum){
        ModelAndView mView = new ModelAndView();

        Map<String, Object> map = recipeService.getRecipeList(pageNum);

        mView.addObject("recipeList", map.get("recipeDtoList"));
        mView.addObject("pagination", map.get("pageDto"));
        mView.setViewName("index");

        return mView;
    }

 Index page 에 pagination을 추가하고, 그에 맞는 게시글의 목록을 화면에 출력하기 위해 추가한 controller의 method이다. 왼쪽 이미지는 기존의 index page로의 요청에 대한 controller method인데, 단순히 pagination을 위한 Dto를 response 하는 사항을 추가해준 것이다. 그리고 나눈 page로의 이동 요청에 대한 method를 새롭게 추가해주었는데, 동작 사항은 동일하지만, 새롭게 요청 url에 대해 path variable을 받는 것으로 설정하였다.

 사실 단순 index page로의 이동 요청과 1번 page로의 이동 요청에 대한 결과는 동일하지만 이들을 합칠 방법이 떠오르지는 않았고, 때문에 기존 index page로의 이동 요청에 대해서는 page number 1을 받도록 수정해주었다.

 

(23.02.22 수정)

 위와 같이 작업했으나, 바로 마이 페이지에서의 pagination 작업 중에 방법을 찾았다.

 

PageController.java

    // index page + 출력할 게시글 목록 with pagination
    @GetMapping(value = {"/", "/page/{pageNum}"})
    public ModelAndView main(@PathVariable(value = "pageNum", required = false) Integer pageNum){
        if(pageNum == null){
            pageNum = 1;
        }

        ModelAndView mView = new ModelAndView();

        Map<String, Object> map = recipeService.getRecipeList(pageNum);

        mView.addObject("recipeList", map.get("recipeDtoList"));
        mView.addObject("pagination", map.get("pageDto"));
        mView.setViewName("index");

        return mView;
    }

 포인트는 이미지에서 표기한 부분이다. 우선 index page는 사실상 1페이지를 보고 있는 것이므로 여러 경로 요청에 대한 응답을 위해 중괄호 { } 로 감싸서 두 종류의 경로 요청에 대한 것임을 표기했다. 그리고 전달 받는 path variable이 꼭 존재할 필요가 없는 index page 에 대한 경로 요청의 경우를 생각해서 @PathVariable의 required 옵션을 false로 해주면서, 전달되는 path variable이 없어서 null 이 되는 경우 1로 초기화시켜주었다.

 

java.lang.IllegalStateException: 
	Optional int parameter 'pageNum' is present but cannot be translated into a null value due to being declared as a primitive type.
	Consider declaring it as object wrapper for the corresponding primitive type.

 이때 주의해야 할 점은 전달 받는 pageNum 의 type을 primitive type으로 하면 안된다는 것이다. @PathVariable의 required 옵션을 false로 했을 때 만약 전달되는 값이 없다면 그 값을 null 로 변환하려고 하는데, primitive type인 경우는 null 값을 받을 수 없기 때문이다. 따라서 wrapper class를 활용하라고 친절하게 메시지를 띄워주니 혹시 type을 잘못 작성했다면 수정해주도록 한다.

 

RecipeServiceImpl.java

    // index page 게시글 목록 조회
    @Override
    public Map<String, Object> getRecipeList(int pageNum) {
        int pageIndex = pageNum - 1;
        // 한 페이지에 몇 개
        int groupSize = 6;
        // 페이징을 몇 개로
        int pageCounts = 5;

        // Repository method에 전달할 Pageable 객체 (index 부터 size 개수만큼)
        PageRequest pageable = PageRequest.of(pageIndex, groupSize);

        // Data를 조회하여 response 할 RecipeDto로 변환
        Page<Recipe> recipeList = recipeRepository.findAll(pageable);
        List<RecipeDto> recipeDtoList = new ArrayList<>();
        recipeList.forEach(tmp -> {
            recipeDtoList.add(tmp.toDto());
        });

        // 게시글 data 전체의 size
        int totalPageNum = recipeList.getTotalPages();

        // pagination에서 보이는 시작 page number와 끝 page number
        int startPageNum = ((pageNum - 1) / pageCounts) * pageCounts + 1;
        int endPageNum = ((pageNum - 1) / pageCounts) * pageCounts + pageCounts;

        // Page의 전체 수가 끝 page number에 해당하지 않으면 끝 page number를 대체
        if(totalPageNum < endPageNum){
            endPageNum = totalPageNum;
        }

        // Pagination을 위해 response 할 data를 담는 PageDto
        PageDto pageDto = PageDto.builder()
                .startPageNum(startPageNum)
                .pageNum(pageNum)
                .endPageNum(endPageNum)
                .totalPageNum(totalPageNum)
                .build();

        // Map에 담아 Controller로
        Map<String, Object> map = new HashMap<>();
        map.put("recipeDtoList", recipeDtoList);
        map.put("pageDto", pageDto);

        return map;
    }

 기존에 있던 index page로 게시글의 목록을 조회하여 전달해주는 service method 를 pagination을 위한 Page 객체로 조회하고, 이를 PageDto로 변환하여 return 하도록 수정했다.

 

 기존과 달라진 부분은 왼쪽 이미지의 붉게 표기된 부분, 그리고 오른쪽 이미지 전체에 해당한다. 우선 왼쪽 이미지에서 활용한 Page 객체를 위한 Pageable 자리에는 PageRequest 객체가 들어가는데, 이때 필수로 들어가야 하는 것이 page로 구분되는 index와 해당 index로부터 몇 개만큼을 조회할 것인지에 대한 size이다. 즉, 조회하는 전체의 data에 대해 size 에 맞게 구분하여 가장 앞 group부터 0번 index를 부여받게 되는 것이다.

 이제부터는 오른쪽 이미지에 대한 설명이다.

 

 Page 객체에서는 page로 구분된 전체 page의 수를 확인할 수 있어서 totalPageNum 이라는 variable로 받았다. 그리고 pagination의 양 끝 숫자를 출력하기 위핸 startPageNum과 endPageNum을 계산하도록 했다. 이때 각각은 이미지의 1과 3에 해당한다. 만약 Next를 눌렀을 때의 값을 생각해본다면 4와 6이 될 것이다.

 

 일반적인 수식이었다면 pageCounts 가 cancel out 되어서 startPageNum은 pageNum이 되고 endPageNum은 pageNum-1+pageCounts가 되어야겠지만, 실제로는 괄호 안을 먼저 계산하여 cancel out이 되지 않는다. 예를 들어 pageNum이 3일 때의 예를 들면 startPageNum에 대한 계산은 '((3-1) / 5) * 5 + 1' 이 되는데, 먼저 2/5에 대한 연산이 수행되면서 int를 int로 나누는 것이기 때문에 0이 된다. 따라서 '0 * 5 + 1' 의 연산이 되어 결국 startPageNum은 1이라는 값이 되는데, 이는 pageNum이 1부터 5까지 모두 동일한 값으로 계산된다. 이어서 endPageNum도 마찬가지의 logic이다.

 체크해야 하는 것은 항상 startPageNum은 항상 존재할 수 있지만, endPageNum이 항상 존재할 수는 없다는 것이다. 위 pagination 이미지를 예로 들면 전체 페이지가 15개여서 3으로 나누어 떨어지는 경우라면 존재하겠지만, 하나 모자란 14개의 페이지인 경우에는 15번 페이지에 대한 출력을 할 필요가 없는 것이다. 따라서 전체 페이지 수인 totalPageNum이 endPageNum보다 작을 경우에는 endPageNum을 totalPageNum으로 갱신해주는 부분을 넣었다.

 

 이렇게 계산된 항목들을 PageDto에 담에서 controlle를 통해 client로 response 하도록 했다.

 

PageDto.java

package com.example.recipository.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PageDto {
    private int startPageNum;
    private int pageNum;
    private int endPageNum;
    private int totalPageNum;
}

 Client에서의 pagination에 필요한 4가지 항목에 대한 Dto class이다.

 

SpringSecurityConfig.java

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(request ->
                        request.antMatchers("/", "/page/**", "/signin-form", "/signin",
                                        "/duplcheck", "/contents/**", "/banned")
                                .permitAll()
                                .anyRequest().authenticated()
                )
                (...후략...)

 

 각 페이지로의 요청에 대한 접근을 모두에게 허용하기 위해 SpringSecurityConfig 에서 antMatchers에 추가해주었다.

 

limit a : index a까지
limit a, b : index a부터 b개 만큼
limit a offset b : index b부터 a번째 index까지

 Pagination 기능을 구현했고 예상한대로 동작하지만,  동작하는 query 또한 역시 예상대로 게시글 data를 먼저 조회하고 그와 관련된 사용자 data를 조회하고 있는 것을 확인하였다. 게시글을 조회하는 query에서는 limit 이 사용되었는데 간단히 작성해두었다. 그리고 사용하든 사용하지 않든 count 값을 출력하는 select query가 동작하는데, 필자는 어차피 이 값을 필요로 해서 괜찮다고 생각한다.

 전체 게시글을 group화 하였기 때문에 이대로 두어도 기존보다는 필요한 query 수가 많지는 않겠지만, 그래도 뭔가 찝찝한 느낌이다.

 

 그래서 다시금 JPQL을 활용하기로 했다. 하지만 바로 생각이 멈췄는데, 그 이유는 JPQL을 어떤 식으로 활용해야 할 지 답이 나오지 않았기 때문이다. 사실 이전까지 JPQL을 활용했을 때는 단순히 전달할 parameter를 인자로 하여 data를 조회하거나 했는데, 이번에는 원하는 페이지에 대한 게시글 목록만을 조회하면서 동시에 전체 게시글의 수를 알아야 한다. 이는 가장 간단히 생각하면 동작하는 query 이미지에서 볼 수 있는 두 가지 query에 대한 repository 의 query method를 작성해서 동작하도록 하면 되는 일이다.

 

 하지만 역시 방법이 있었고, 그것을 천천히 소개해보고자 한다.

 

RecipeRepository.java

    @Query(value = "select r from Recipe r join fetch r.member",
            countQuery = "select count(r) from Recipe r")
    Page<Recipe> getAllWithPagination(Pageable pageable);

 눈여겨 보아야 할 것은 이미지에 표기한 세 군데이다.

 우선 작성한 query에는 앞서 보았던 limit 과 같은 친구는 존재하지 않는데, 이는 Pageable 객체를 전달받는 자리에 PageRequest 객체를 전달하는 것으로 관련 query가 더해지는 구조가 된다고 추측할 수 있다. 보통은 이 정도 선에서 기능이 정상적으로 동작하는데, 필자는 다음과 같은 오류를 보게 되었다.

 

Caused by: 
	org.springframework.beans.factory.UnsatisfiedDependencyException: 
		Error creating bean with name 'authInterceptor' defined in file 
		[C:\Users\kyuhwan\Desktop\Projects\recipository\build\classes\java\main\com\example\recipository\interceptor\AuthInterceptor.class]: 
			Unsatisfied dependency expressed through constructor parameter 0; 
nested exception is org.springframework.beans.factory.BeanCreationException: 
	Error creating bean with name 'recipeRepository' defined in com.example.recipository.repository.RecipeRepository 
	defined in @EnableJpaRepositories declared on JpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration: 
	Invocation of init method failed; 
nested exception is org.springframework.data.repository.query.QueryCreationException: 
	Could not create query for public abstract org.springframework.data.domain.Page 
	com.example.recipository.repository.RecipeRepository.getAllWithPagination(org.springframework.data.domain.Pageable); 
Reason: Count query validation failed for method public abstract org.springframework.data.domain.Page 
	com.example.recipository.repository.RecipeRepository.getAllWithPagination(org.springframework.data.domain.Pageable)!; 
nested exception is java.lang.IllegalArgumentException: 
	Count query validation failed for method public abstract org.springframework.data.domain.Page 
	com.example.recipository.repository.RecipeRepository.getAllWithPagination(org.springframework.data.domain.Pageable)!

 상당히 길지만, 보아야 할 부분은 'Reason' 부분의 Count query validation failed 라는 말이다. 글자 그대로 count query에 대한 validation이 실패했다는 것인데, count query는 이전에 동작하는 query에서 봤던 'select count ~ blah ~ blah' 의 query를 말한다. 그런데 여기에서 오류가 발생한 것은 동작하도록 하는 query문에 'fetch' 가 포함되어 있기 때문이라고 한 강사분이 언급한 것을 발견했다.[각주:1] 그러면서 countQuery 옵션으로 count query를 별도로 작성해주어야 한다고 말하며 참조할 링크를 알려주었다.[각주:2]

 

 그렇게 동작하는 query는 위의 두 개이다. 이것이 필자가 원하는 것이었다.

 

 

 이렇게 index page에 대한 pagination 작업을 마무리했다. 이와 같은 방식으로 사용자의 마이 페이지에서 볼 수 있는 사용자가 작성한 게시글 목록에서도 pagination 작업을 해줄 것이나, 방식은 같기 때문에 따로 게시글로 정리하지는 않을 것이다. 사실 딱 여기까지 하고 잠시 마무리를 지으려고 했는데, 생각해보니 검색 기능을 깜빡했다.

 앞으로 남은 작업을 간단히 정리해보면 검색 기능, 그리고 포트폴리오를 위한 이미지 출력 때문에 이미지 저장과 출력에 대한 작업을 하고 잠시 이 작업은 중단할 것 같다. 물론 최종적인 목표는 이 친구를 AWS에도 올려보는 것이기에 이미지 저장 및 출력에 대한 작업은 그쪽에서 다시 이어질 것 같다.

 

Reference