본문 바로가기
Spring

Pageable을 활용해 타임리프로 페이징 처리 구현하기

by 코더 제이콥 2023. 7. 20.

1. 개요

포스팅에 앞서 이 포스팅은 페이징 처리에 관련해서만 내용을 담았습니다. 들어가기 앞서 용어를 정리해야 할 거 같은데요.

 

아마 페이징 처리를 한다는 건 위처럼 하는 것을 의미할 겁니다.(더미 데이터라 그러려니 하고 봐주세요)

  • 페이지 그룹 : 페이지들을 모은 상위 카테고리 개념입니다. 페이지 그룹 사이즈만큼 페이지가 생길 것입니다. 위 이미지에서는 페이지 그룹 사이즈를 2로 설정했기 때문에 페이지는 두 개가 나옵니다.
  • 페이지 : 페이지에는 정해둔 페이지 사이즈만큼 컨텐츠가 나올 겁니다. 게시판이면 게시글이, 쇼핑몰이면 상품들이 컨텐츠에 해당하겠습니다. 저는 e-book 서점이므로 도서의 정보가 컨텐츠로 나오는데 만약 페이지 사이즈를 5개로 설정했다면 페이지당 도서의 정보가 5개씩 나와야 합니다.

전체적인 사이트 모습 - 그룹 사이즈는 3, 페이지 사이즈는 2

위와 같이 어떻게 구현했는지 차근차근 알아보겠습니다.

일단 그 전에, JPA, QueryDSL, Thymeleaf를 사용했고 스프링에서 제공하는 Pageable을 활용해서 페이징 처리를 했습니다. 각설하고 코드 보시죠.

2. 코드

컨트롤러

@GetMapping("/search")
public String search(@PageableDefault(size = 2) Pageable pageable,
                     @RequestParam String query,
                     @ModelAttribute BookSearchCondition condition,
                     Model model){
    Page<BookSearchResponse> responses =  searchService.search(query, pageable, condition);

    PagingDto paging = new PagingDto(responses);

    model.addAttribute("responses", responses);
    model.addAttribute("query", query);
    model.addAttribute("paging", paging);
    return "search/search";
}

컨트롤러입니다. 메소드의 파라미터로 Pageable 객체를 받았습니다.

  • @PageableDefault(size = x) : 해당 애노테이션은 페이지 사이즈를 결정합니다. 위에서 페이지 사이즈만큼 컨텐츠가 나온다고 했으니 중요한 설정입니다. 저는 자료가 없으면서 페이징 처리도 해야 해서 2개로 설정했습니다.
  • @RequestParam String query : DB에 where절의 조건문으로 들어갈 예정입니다.
  • @ModelAttribute BookSearchCondition condition : QueryDSL에서 동적 쿼리문을 사용하기 위해 받은 DTO인데, 해당 포스팅은 페이징에 관련된 내용이 주된 내용이므로 생략하겠습니다.
  • BookSearchResponse : DTO입니다. 데이터베이스에서 받은 컨텐츠의 값들을 가지고 있습니다.
  • PagingDTO paging : 페이징 처리에 관련 핵심 로직을 담고 있습니다. 뒤에서 설명하겠습니다.

전체적인 플로우는 서비스단에 메소드를 호출한 후에 model에 담은 것입니다. 만약, 리엑트와 같이 클라이언트 사이드 렌더링을 사용했다면 model에 담지 않고 JSON으로 보내면 되겠습니다.

처음부터 말씀드렸다싶이 이 포스팅은 페이징 관련 처리만 알아보겠습니다. 서비스단과 리포지토리단은 여러분들만의 로직이 있으실 겁니다. 그래서 리포지토리만 가능한 짧게 언급하겠습니다.

리포지토리

public class SearchBookRepositoryImpl implements SearchBookRepository{
    private final JPAQueryFactory queryFactory;

    public SearchBookRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public Page<BookSearchResponse> search(String query, Pageable pageable, BookSearchCondition condition) {
        JPAQuery<Long> countQuery = queryFactory
                .select(book.count())
                .from(book)
                .where(SearchCondUtils.contains(book.title, query));
        
        List<BookSearchResponse> content = 
        		..코드 생략
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

컨트롤러에서 메소드 파라미터로 받은 Pageable 인터페이스의 getOffset() 메소드와 getPageSize() 메소드를 활용하여 페이징처리를 할 수 있습니다. offset은 페이지와 페이지 사이즈로 내부에서 알아서 결정되니 신경쓸 필요 없고, 페이지 사이즈는 @PageableDefault(size = x)의 설정으로 결정됩니다.

JPA에서는 page 번호가 0번부터 시작합니다. 여기서 좀 헷갈리는데요.

예를 들어 위 그림에서 페이지 그룹에는 페이지 번호가 1, 2인 페이지들이 있는데 원래는 0, 1인 겁니다. 하지만 사용자 인터페이스에서 0, 1로 되어 있으면 '이게 뭐지?'라고 생각할테니까 1을 더해줘야 합니다. 아무튼 이건 나중에 타임리프에서 언급하겠습니다.

아무튼 offset에서 pageSize만큼 컨텐츠를 가져온다고 생각하시면 되겠습니다. 예를 들어 현재 페이지 번호가 0번이고 페이지 사이즈가 5개라면, 페이지 0에는 offset이 0인 상태에서 컨텐츠 0,1,2,3,4가 담기고 페이지 번호가 1번이면 페이지 1에는 offset이 5인 상태에서 컨텐츠 5,6,7,8,9이 담기는 식입니다. 컨텐츠의 순서는 최신순, 리뷰 많은 순 등 where절의 조건으로 변동되겠죠?

페이지 번호는 Pageable의 PageNumber로 이것은 url로 결정됩니다. 예를 들어 GET: /search?query=해리포터&page=0로 접근했을 때 컨트롤러에서 메소드 파라미터로 Pageable을 받을 때 pageNumber 필드가 0으로 초기화 됩니다.

리포지토리 코드를 보시면 반환할 때 PageableExcutionUtils의 getPage 메소드를 호출하는데요. getPage 메소드의 파라미터로 DB에서 가져온 컨텐츠와 컨트롤러에서 생성한 pageable, 카운트 쿼리를 넘기고 있습니다. getPage 메소드는 Page 인터페이스를 구현한 PageImpl을 반환하는데 이때 메소드 파라미터로 받은 컨텐츠, pageable, 토탈 카운트로 PageImpl 객체를 초기화합니다. 그리고 이 Page 객체를 활용하여 페이징처리를 하면 되겠습니다.

Page, Slice 인터페이스 짧막 정리

우리가 사용하는 Page 인터페이스는 사실 Slice 인터페이스를 상속받은 인터페이스입니다. 각각 스펙을 잠시 살펴보겠습니다.

public interface Slice<T> extends Streamable<T> {
	// 컨트롤러에서 초기화된 Pageable의 현재 페이지 번호인 pageNumber로 초기화 됨
	int getNumber();
	
   	// 컨트롤러에서 초기화된 Pageable의 pageSize를 리턴함
	int getSize();
	
   	// 컨텐츠 수량으로 리포지토리에서 보낸 토탈 카운트 쿼리로 구해짐. 검색된 자료가 12건이면 12를 반환
	int getNumberOfElements();

	// 컨텐츠를 가져오는 메소드
	List<T> getContent();
	
	boolean hasContent();

	Sort getSort();

	// 가장 처음인 페이지 번호인지
	boolean isFirst();

	// 가장 마지막인 페이지 번호인지
	boolean isLast();
	
  	// 다음 페이지가 있는지
	boolean hasNext();
	
   	// 이전 페이지가 있는지
	boolean hasPrevious();

	default Pageable getPageable() {
		return PageRequest.of(getNumber(), getSize(), getSort());
	}

	Pageable nextPageable();

	Pageable previousPageable();

	<U> Slice<U> map(Function<? super T, ? extends U> converter);

	default Pageable nextOrLastPageable() {
		return hasNext() ? nextPageable() : getPageable();
	}

	default Pageable previousOrFirstPageable() {
		return hasPrevious() ? previousPageable() : getPageable();
	}
}

Slice 인터페이스는 유튜브 동영상은 스크롤을 내리면 내릴 수록 동영상이 생기는 방식인데, 비슷하다고 생각하시면 됩니다. 예를 들어 댓글에서 댓글 10개 더보기란 식으로 구현할 수 있습니다. 리엑트와 같은 클라이언트 사이드 랜더링이라면 요청이 올 때 JSON으로 보내주면 편한데, 저처럼 클라이언트 사이드 랜더링인 타임리프를 사용하셨다면 구현하시는 데 애로사항이 조금 있습니다.

 

public interface Page<T> extends Slice<T> {

	static <T> Page<T> empty() {
		return empty(Pageable.unpaged());
	}

	static <T> Page<T> empty(Pageable pageable) {
		return new PageImpl<>(Collections.emptyList(), pageable, 0);
	}

    // 페이지의 총 수량을 구할 수 있음
	int getTotalPages();

	long getTotalElements();

	<U> Page<U> map(Function<? super T, ? extends U> converter);
}

 

페이지 인터페이스입니다. 페이지에서는 페이지 총 수량을 알 수 있는 getTotalPages() 메소드와 컨텐츠의 총 수량을 알 수 있는 getTotalElements() 메소드가 있습니다.

지금까지 플로우 정리

1. GET: /search?query=해리포터&page=0으로 클라이언트가 접근

2. 컨트롤러의 search 메소드가 호출

3. 이때 사실 Pageable 객체가 한 번 초기화 되는데 어노테이션 정보를 바탕으로 페이지 사이즈를 x만큼 초기화하고, 쿼리 파라미터로 온 page=0을 통해 pageNumber 필드가 초기화

(설명을 위해 임시로 json을 던지는 컨트롤러 매핑을 만들었습니다.) 포스트맨을 보시면 이미지의 빨간 네모 박스를 보면, page가 1일 때 pageable 객체의 pageNumber 필드 역시 1로 초기화 된 것을 알 수 있음

4. 이렇게 초기화된 pageable 객체를 서비스 -> 리포지토리에 넘김

5. 리포지토리에서는 데이터베이스에 쿼리문을 보내는데 페이징 처리를 위한 offset과 limit 각각 컨트롤러에서 초기화한  pageable의 offset과 pageSize를 넘기면서 처리를 진행

PagingDto

@Getter
public class PagingDto {
    // 페이지 그룹에 최대로 담길 수 있는 페이지 수량
    private final int MAXIMUM_PAGE_NUMBER_IN_PAGE_GROUP = 5;
    
    // 페이지 그룹에 담길 수 있는 페이지 수량
    private int pageGroupSize;
    
    // 몇 개의 페이지 그룹이 있는지 수량
    private int totalPageGroups;
    
    // 페이지 그룹의 번호
    private int pageGroupNumber;
    // 현재 페이지 그룹 번호에서 시작하는 페이지 번호
    private int startPageNumberInThisPageGroup;
    // 현재 페이지 그룹 번호에서 끝나는 페이지 번호
    private int lastPageNumberInThisPageGroup;

    // 이전 페이지 번호
    private int prevPageNumber;
    // 다음 페이지 번호
    private int nextPageNumber;
    
    // 최초 페이지 번호
    private int firstPageNumber;
    // 마지막 페이지 번호
    private int lastPageNumber;

    private List<Integer> pageGroupNumbers = new ArrayList<>();

    private boolean isFirstGroup;
    private boolean isLastGroup;


    public PagingDto(Page<BookSearchResponse> responses) {
        // Page 인터페이스의 페이지 총 수량 구함
        int totalPages = responses.getTotalPages();

        pageGroupSize = MAXIMUM_PAGE_NUMBER_IN_PAGE_GROUP;

        totalPageGroups = calculateTotalPageGroups(totalPages);
        pageGroupNumber = calculatePageGroupNumber(responses);

        startPageNumberInThisPageGroup = calculateStartPageNumber();
        lastPageNumberInThisPageGroup = calculateLastPageNumber(totalPages, startPageNumberInThisPageGroup);

        prevPageNumber = responses.getNumber() - 1;
        nextPageNumber = responses.getPageable().getPageNumber() + 1;

        firstPageNumber = 0;
        // 페이지는 0부터 시작하므로 -을 해줘야함.
        lastPageNumber = responses.getTotalPages() - 1;
    }

    private int calculateLastPageNumber(int totalPages, int startPageNumberInThisPageGroup) {
        int tempLastPageNumber = startPageNumberInThisPageGroup + pageGroupSize - 1;
        return tempLastPageNumber < totalPages ? tempLastPageNumber : totalPages;
    }

    private int calculateTotalPageGroups(int totalSearchResultCount) {
        // 총 블록 개수는 = 총 검색 결과 / 블록 사이즈 -> 소수점은 무조건 반올림 처리
        return (int) Math.ceil(totalSearchResultCount * 1.0 / pageGroupSize);
    }

    private int calculatePageGroupNumber(Page<BookSearchResponse> responses) {
        // double형이 아닌, int형이므로 소수점은 자동 제거
        return responses.getNumber() / pageGroupSize;
    }

    private int calculateStartPageNumber() {
        return (pageGroupNumber) * pageGroupSize + 1;
    }
}

갑작스런 코드 폭탄 죄송합니다. 필드는 주석으로 다 이해 되셨다고 생각하고, 생성자 코드 보겠습니다.

// Page 인터페이스의 페이지 총 수량 구함
int totalPages = responses.getTotalPages();

totalPageGroups = calculateTotalPageGroups(totalPages);
pageGroupNumber = calculatePageGroupNumber(responses);

private int calculateTotalPageGroups(int totalSearchResultCount) {
    // 총 블록 개수는 = 총 검색 결과 / 블록 사이즈 -> 소수점은 무조건 반올림 처리
    return (int) Math.ceil(totalSearchResultCount * 1.0 / pageGroupSize);
}

private int calculatePageGroupNumber(Page<BookSearchResponse> responses) {
    // double형이 아닌, int형이므로 소수점은 자동 제거
    return responses.getNumber() / pageGroupSize;
}

페이지 그룹의 개수와, 현재 페이지의 번호입니다.

이해를 돕기 위해 이미지를 만들어봤습니다. 위 이미지에서 페이지 그룹 개수는 2개이며 각각 번호는 1번과 2번입니다.

startPageNumberInThisPageGroup = calculateStartPageNumber();
lastPageNumberInThisPageGroup = calculateLastPageNumber(totalPages, startPageNumberInThisPageGroup);

private int calculateStartPageNumber() {
    return (pageGroupNumber) * pageGroupSize + 1;
}

private int calculateLastPageNumber(int totalPages, int startPageNumberInThisPageGroup) {
    int tempLastPageNumber = startPageNumberInThisPageGroup + pageGroupSize - 1;
    return tempLastPageNumber < totalPages ? tempLastPageNumber : totalPages;
}

페이지 그룹에서 시작 페이지와 마지막 페이지를 결정하는 코드입니다.

위의 이미지에서 페이지 그룹 1번의 시작 페이지 번호는 1, 마지막 페이지 번호는 2이고 페이지 그룹 2번는 각각 3, 4가 되겠습니다.

다만 여기서, 마지막 번호를 구할 때 추가적인 로직이 들어갑니다. 이거 하느라 두 시간은 쓴 거 같은데, 역시 구글링이 짱이네요. 일단 현재 그룹에서 마지막 번호는 시작 페이지 번호 + 그룹 사이즈 - 1입니다. 이때 비교를 하는데, 만약 검색된 총 페이지보다 마지막 시작 페이지가 작거나 클 때 각각 값을 할당합니다.

예를 들어 마지막 시작 페이지가 10페이지가 나왔는데, 검색된 총 페이지가 8개면 10개가 아닌 8개로 되야 합니다. 위 코드로 구현하면 다음과 같이 처리됩니다.

보시면 두 번째 페이지 그룹에서, 원래는 6 + 5 - 1로 페이지가 6, 7, 8, 9, 10이 나왔어야 했는데 제가 준 조건문으로 인해 총 검색된 페이지의 수가 7개였고, 따라서 7로 결정된 모습입니다.

firstPageNumber = 0;
// 페이지는 0부터 시작하므로 -을 해줘야함.
lastPageNumber = responses.getTotalPages() - 1;

최초의 페이지 번호와 마지막 페이지 번호입니다. JPA에서는 page가 0부터 시작한다고 말씀드렸으니 설명은 더 생략하겠습니다.

3. 프론트엔드 코드

<nav aria-label="Page navigation">
<ul class="pagination">
  <li class="page-item" th:class="${responses.first} ? 'disabled'">
    <a class="page-link" th:href="@{/search(query=${query}, page=${paging.getFirstPageNumber})}" aria-label="Previous">
      <span aria-hidden="true">&laquo;&laquo;</span>
    </a>
  </li>
  <li class="page-item" th:class="${responses.first} ? 'disabled'">
    <a class="page-link" th:href="@{/search(query=${query}, page=${paging.getPrevPageNumber})}" aria-label="Previous">
      <span aria-hidden="true">&laquo;</span>
    </a>
  </li>
  <th:block th:with="start = ${paging.getStartPageNumberInThisPageGroup}, end = ${paging.getLastPageNumberInThisPageGroup}">
    <li class="page-item" th:each="num : ${#numbers.sequence(start, end)}" th:class="${responses.pageable.pageNumber eq num - 1} ? 'active' : ''">
      <a class="page-link" th:href="@{/search(query=${query}, page=${num} - 1)}" th:text="${num}">1</a>
    </li>
  </th:block>
  <li class="page-item" th:class="${responses.last} ? 'disabled' : ''">
    <a class="page-link" th:href="@{/search(query=${query}, page=${paging.getNextPageNumber})}" aria-label="Next">
      <span aria-hidden="true">&raquo;</span>
    </a>
  </li>
  <li class="page-item" th:class="${responses.last} ? 'disabled' : ''">
    <a class="page-link" th:href="@{/search(query=${query}, page=${paging.getLastPageNumber})}" aria-label="Next">
      <span aria-hidden="true">&raquo;&raquo;</span>
    </a>
  </li>
</ul>
</nav>

css 프레임워크로 부트 스트랩을 사용했습니다.

<th:block th:with="start = ${paging.getStartPageNumberInThisPageGroup}, end = ${paging.getLastPageNumberInThisPageGroup}">
    <li class="page-item" th:each="num : ${#numbers.sequence(start, end)}" th:class="${responses.pageable.pageNumber eq num - 1} ? 'active' : ''">
      <a class="page-link" th:href="@{/search(query=${query}, page=${num} - 1)}" th:text="${num}">1</a>
    </li>
</th:block>

th:with을 통해 block의 범주 안에서 변수들을 사용할 수 있습니다. start에는 해당 페이지 그룹의 시작 페이지 번호를, end에는 마지막 페이지 번호를 담았습니다.

th:each에서 start와 end의 범위 만큼 li 태그를 생성합니다. a 태그에서 href는 num - 1로 설정했는데, JPA에서는 page가 0부터 시작하기 때문에 -1을 해준 겁니다.