Java/Spring

[SpringBoot] νŽ˜μ΄μ§€λ„€μ΄μ…˜(Pagination)

벼리01 2024. 3. 26. 23:10

πŸ“Œν™˜κ²½

IntelliJ Ultimate

Java 17

SpringBoot 3.2.3

Gradle - Groovy

 

Dependencies: 

Spring Web

Thymeleaf

Spring Data JPA

lombok

MariaDB 10.11

Spring Dev tool

QueryDSL 5.0.0

 

 

πŸ“ŒνŽ˜μ΄μ§€λ„€μ΄μ…˜(Pagination)μ΄λž€?

νŽ˜μ΄μ§• λ˜λŠ” νŽ˜μ΄μ§€λ„€μ΄μ…˜μ΄λΌκ³  λΆ€λ₯Έλ‹€. λ‹€λŸ‰μ˜ 데이터λ₯Ό κ³΅ν†΅λœ 개수만큼 μ—¬λŸ¬ 묢음으둜 λ‚˜λˆ„μ–΄ νŠΉμ • νŽ˜μ΄μ§€ λ‹Ή ν•œ λ¬ΆμŒμ„ 화면에 λ³΄μ—¬μ£ΌλŠ” 것을 λ§ν•œλ‹€.

DB에 λ“±λ‘λœ 데이터가 λ§Žμ€ 경우 μš”μ²­ μ‹œ 전체λ₯Ό ν•œλ²ˆμ— λ“€κ³ μ˜¨λ‹€λ©΄ 응닡 μ‹œκ°„μ΄ κΈΈμ–΄μ§€κ²Œ λ˜λ©΄μ„œ ν΄λΌμ΄μ–ΈνŠΈμ—κ²Œ λΆˆνŽΈμ„ μ΄ˆλž˜ν•œλ‹€. μ΄λ•Œ 데이터λ₯Ό νŽ˜μ΄μ§€μ— ν•΄λ‹Ήν•˜λŠ” 개수만큼 μΌλΆ€λ§Œ μž˜λΌμ„œ κ°€μ Έμ˜¨λ‹€λ©΄ μ‹œκ°„μ„ 단좕할 수 μžˆλ‹€. 예λ₯Ό λ“€μ–΄ ν™”λ©΄μ—μ„œ 1νŽ˜μ΄μ§€λ₯Ό μš”μ²­ν–ˆλ‹€λ©΄ λ°±μ—”λ“œμ—μ„œλŠ” μ •λ ¬ν•œ 데이터 쀑 첫번째 데이터뢀터 10번째 λ°μ΄ν„°κΉŒμ§€λ§Œ μž˜λΌμ„œ λ°˜ν™˜ν•œλ‹€. 

 

DBμ—μ„œ μ›ν•˜λŠ” 만큼만 잘라였기 μœ„ν•΄μ„œλŠ” `offset`κ³Ό `limit`을 μ‚¬μš©ν•˜λŠ”λ°, `offset`은 κ±΄λ„ˆλ›Έ λ°μ΄ν„°μ˜ 개수, `limit`은 μž˜λΌλ‚Ό λ°μ΄ν„°μ˜ 개수λ₯Ό λ§ν•œλ‹€. νŽ˜μ΄μ§€λ‹Ή 데이터λ₯Ό 10κ°œμ”© λ³΄μ—¬μ£ΌλŠ” ν™”λ©΄μ—μ„œ 2νŽ˜μ΄μ§€λ₯Ό μš”μ²­ν–ˆλ‹€λ©΄ 쿼리문은 `select * from table limit 10 offset 10`이 λœλ‹€. λ˜λŠ” `select * from table limit 10, 10`κ³Ό 같이 `limit`λ§ŒμœΌλ‘œλ„ 쿼리문을 μž‘μ„±ν•  수 μžˆλ‹€.

 

 

πŸ“ŒSpringBoot - Spring Data JPA의 νŽ˜μ΄μ§•

JDBC λ˜λŠ” Mybatisλ₯Ό μ‚¬μš©ν•œλ‹€λ©΄ νŽ˜μ΄μ§€λ₯Ό 직접 쿼리문으둜 κ΅¬ν˜„ν•΄μ•Όν•˜μ§€λ§Œ, SpringBootμ—μ„œλŠ” Spring Data JPAλ₯Ό μ‚¬μš©ν•˜μ—¬ νŽ˜μ΄μ§€μ— κ΄€ν•œ 처리λ₯Ό `Pageable`κ³Ό `Page<E>`νƒ€μž…μœΌλ‘œ 닀루고 μžˆλ‹€.

 

 

 

 

`Pageable` νƒ€μž…μ˜ 객체λ₯Ό κ΅¬μ„±ν•˜μ—¬ `repository` λ‚΄ λ©”μ„œλ“œμ˜ νŒŒλΌλ―Έν„°λ‘œ μ „λ‹¬ν•˜λ©΄ κ²°κ³Όλ₯Ό `Page<T>`둜 λ°˜ν™˜λ°›μ„ 수 μžˆλ‹€.  `Pageable`λŠ” μΈν„°νŽ˜μ΄μŠ€λ‘œ, νŽ˜μ΄μ§€ 생성 쑰건을 κ°€μ§„ 객체라고 μƒκ°ν•˜λ©΄ λœλ‹€. νŽ˜μ΄μ§€ 번호, κ°€μ Έμ˜¬ 데이터 개수, μ •λ ¬ 방식을 λ‹€μŒκ³Ό 같이 ꡬ성할 수 μžˆλ‹€.

 

//	1νŽ˜μ΄μ§€(0 ~ 10), bno둜 λ‚΄λ¦Όμ°¨μˆœ μ •λ ¬
Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());

 

 

이제 이 `pageable`을 `repository`의 λ©”μ„œλ“œμ— νŒŒλΌλ―Έν„°λ‘œ μ „λ‹¬ν•˜λ©΄ `Page<E>` 객체λ₯Ό 얻을 수 μžˆλ‹€.

 

//	1νŽ˜μ΄μ§€(0 ~ 10), bno둜 λ‚΄λ¦Όμ°¨μˆœ μ •λ ¬
Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());
Page<Board> result = boardRepository.findAll(pageable);

 

 

μ½˜μ†”μ„ 까보면 Hibernateκ°€ λ‹€μŒκ³Ό 같이 쿼리문을 μ‹€ν–‰ν•˜λŠ” 것을 확인할 수 μžˆλ‹€.

 

select
    board0_.bno as bno1_0_,
    board0_.modda t e as moddate2_0_,
    board0_.re gdat e as regdate3_0_,
    board0_.con t ent as content4_0_,
    board0_. titl e as title5_0_,
    board0_.wr it er as writer6_0_
from
    board board0_
order by
    board0_.bno desc limit ?

 

 

`Page<T>` νƒ€μž…μ˜ κ°μ²΄λŠ” λ‚΄λΆ€μ μœΌλ‘œ νŽ˜μ΄μ§• μ²˜λ¦¬μ— ν•„μš”ν•œ μ—¬λŸ¬ 정보λ₯Ό κ°€μ§€κ³  μžˆλ‹€. 전체 λ°μ΄ν„°μ˜ κ°œμˆ˜λŠ” λͺ‡ κ°œμΈμ§€, 총 νŽ˜μ΄μ§€λŠ” λͺ‡ κ°œμΈμ§€, 데이터 κ°œμˆ˜λŠ” λͺ‡ κ°œμΈμ§€ λ“±μ˜ 정보λ₯Ό λͺ¨λ‘ κ°€μ Έμ˜¬ 수 μžˆλ‹€.

 

 

Page<Board> result = boardRepository.findAll(pageable);

result.getTotalElements();		//	총 데이터 개수: limit 쑰건을 κ±Έμ§€ μ•Šμ•˜μ„ λ•Œ 개수
result.getTotalPage();		//	총 νŽ˜μ΄μ§€: μ‚¬μ΄μ¦ˆμ— 맞좰 μž˜λΌλƒˆμ„ λ•Œ νŽ˜μ΄μ§€ 개수
result.getNumber();		//	νŽ˜μ΄μ§€ 번호: κ°€μ Έμ˜¨ νŽ˜μ΄μ§€
result.getSize();			//	νŽ˜μ΄μ§€ λ‹Ή κ°€μ Έμ˜€λŠ” 데이터 개수
result.getContent();		//	κ°€μ Έμ˜¨ 데이터 λͺ©λ‘ List<Board>

 

 

`getTotalElements()`λŠ” 데이터 λͺ©λ‘μ„ κ°€μ Έμ˜€λŠ” 것이 μ•„λ‹ˆλΌ limit 쑰건을 κ±Έμ§€ μ•Šμ•˜μ„ λ•Œμ˜ 총 데이터 개수λ₯Ό λœ»ν•˜λ―€λ‘œ μ£Όμ˜ν•œλ‹€. 데이터 λͺ©λ‘λ§Œ λ”°λ‘œ `List<T>` 객체둜 μ–»κ³  μ‹Άλ‹€λ©΄ `getContent()`λ₯Ό μ‚¬μš©ν•˜λ©΄ λœλ‹€.

 

 

πŸ“Œν™œμš©

ν™”λ©΄μ—μ„œ 검색 쑰건, νŽ˜μ΄μ§€λ₯Ό μš”μ²­ν–ˆμ„ λ•Œ 검색 쑰건을 λ§Œμ‘±ν•œ νŠΉμ • κΆŒν•œμ˜ 멀버λ₯Ό λ“±λ‘μˆœμœΌλ‘œ μ •λ ¬ ν›„ νŽ˜μ΄μ§€ μ²˜λ¦¬ν•˜μ—¬ 화면에 λ³΄μ—¬μ£Όκ³ μž ν•œλ‹€. μ΄λ•Œ 검색 쑰건을 μ μš©ν•˜κΈ° μœ„ν•΄  `queryDSL`을 μ‚¬μš©ν•œλ‹€. 검색 쑰건이 μ‘΄μž¬ν•˜κ±°λ‚˜ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ λ•Œ λͺ¨λ‘ νŽ˜μ΄μ§€ μ²˜λ¦¬ν•˜μ—¬ 데이터λ₯Ό κ°€μ Έμ˜€κ³  μ‹Άλ‹€λ©΄ `RepositoryImpl`은 λ‹€μŒκ³Ό 같이 μž‘μ„±ν•œλ‹€.

 

package com.torobbb.repository.search;

import com.torobbb.domain.Member;
import com.torobbb.domain.MemberRole;
import com.torobbb.domain.QMember;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.JPQLQuery;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

import java.util.List;
import java.util.Set;

public class MemberSearchImpl extends QuerydslRepositorySupport implements MemberSearch {

    public MemberSearchImpl() {
        super(Member.class);
    }

    @Override
    public Page<Member> searchMember(String[] types, String keyword, Pageable pageable, Set<MemberRole> memberRoleSet) {

//        QueryDSL 객체
        QMember member = QMember.member;

//        select ... from member
        JPQLQuery<Member> query = from(member);

//        order by regDate desc;
        query.orderBy(member.regDate.desc());

        BooleanBuilder booleanBuilder = new BooleanBuilder();

        if(types != null && keyword != null){

            for(String type : types){
                switch (type){
                    case "i" :
                        //        where id like ...
                        booleanBuilder.or(member.id.contains(keyword));
                        break;
                    case "n" :
                        //        or name like ...
                        booleanBuilder.or(member.name.contains(keyword));
                        break;
                }
            }// for
        }// if

//        and roleSet like
        memberRoleSet.forEach(memberRole -> {
            booleanBuilder.and(member.roleSet.contains(memberRole));
        });

        query.where(booleanBuilder);

//        νŽ˜μ΄μ§•
        this.getQuerydsl().applyPagination(pageable, query);

        List<Member> list = query.fetch();
        long count = query.fetchCount();

        return new PageImpl<>(list, pageable, count);
    }
}

 

 

 

`PageImpl<T>`λŠ” `Page<T>` μΈν„°νŽ˜μ΄μŠ€μ˜ κ΅¬ν˜„ 객체둜 μ‹€μ œ λͺ©λ‘ 데이터(List<T>), νŽ˜μ΄μ§€ 정보(Pageable), 전체 개수(long)을 νŒŒλΌλ―Έν„°λ‘œ μ „λ‹¬ν•˜λ©΄ `Page<T>` νƒ€μž…μ˜ μ°Έμ‘° λ³€μˆ˜λ‘œ μ°Έμ‘°ν•˜μ—¬ μ‚¬μš©ν•  수 μžˆλ‹€. 

ν•΄λ‹Ή `Repository`의 `searchMember()` λ©”μ„œλ“œλ₯Ό μ‚¬μš©ν•˜λŠ” `ServiceImpl`은 λ‹€μŒκ³Ό κ°™λ‹€.

 

    @Override
    public PageResponseDTO<Member> findBySpecificRoles(Set<MemberRole> memberRoleSet, PageRequestDTO pageRequestDTO) {
        Pageable pageable = pageRequestDTO.getPageable();

        String[] types = pageRequestDTO.getTypes(); // split("_")
        String keyword = pageRequestDTO.getKeyword();

        log.info("types: " + Arrays.toString(types));
        log.info("keyword: " + keyword);

//        검색 μΉ΄ν…Œκ³ λ¦¬, ν‚€μ›Œλ“œ, νŠΉμ • κΆŒν•œμ„ κ°€μ§„ 멀버 regDate λ‚΄λ¦Όμ°¨μˆœμœΌλ‘œ νŽ˜μ΄μ§• μ²˜λ¦¬ν•΄μ„œ κ°€μ Έμ˜΄
        Page<Member> memberPage = memberRepository.searchMember(types, keyword, pageable, memberRoleSet);

//        νŽ˜μ΄μ§€ μ•ˆμ— λ‹΄κΈ΄ dtoList
        List<Member> dtoList = memberPage.getContent();

        return PageResponseDTO.<Member>withAll()
                .pageRequestDTO(pageRequestDTO)
                .dtoList(dtoList)
//                κ²€μƒ‰ν–ˆμ„ λ•Œ 총 개수(νŽ˜μ΄μ§€μ— λ‹΄κΈ΄ 개수 말고 검색 κ²°κ³Ό 개수)
                .total((int)memberPage.getTotalElements())
                .build();
    }

 

 

μ‹€μ œ μ‚¬μš©ν•  λ•ŒλŠ” `Member` μ—”ν‹°ν‹°λ₯Ό λ°”λ‘œ λ°˜ν™˜ν•˜μ§€ 말고 `DTO`둜 λ³€ν™˜ν•˜μ—¬ λ°˜ν™˜ν•˜λ„λ‘ ν•œλ‹€.

ν•΄λ‹Ή λ©”μ„œλ“œλ₯Ό μ‚¬μš©ν•˜λŠ” `Controller`λŠ” λ‹€μŒκ³Ό κ°™λ‹€.

 

 

 @GetMapping("/member")
    public String index(Model model, PageRequestDTO pageRequestDTO){

        Set<MemberRole> memberRoleSet = new HashSet<>();
        memberRoleSet.add(MemberRole.MEMBER);

        PageResponseDTO<Member> pageResponseDTO = memberService.findBySpecificRoles(memberRoleSet, pageRequestDTO);

        model.addAttribute("admin", MemberRole.MEMBER);
        model.addAttribute("pageResponseDTO", pageResponseDTO);

        return "/member/index";
    }

 

 

 

`/member` url을 μš”μ²­ν–ˆμ„ λ•Œ `MEMBER` κΆŒν•œμ„ κ°€μ§„ λ©€λ²„μ˜ 리슀트λ₯Ό 보여쀀닀. `PageRequestDTO`와 `PageResponseDTO`λŠ” νŽ˜μ΄μ§• 처리λ₯Ό μœ„ν•΄ λ·°μ—μ„œ λͺ¨λ“  정보λ₯Ό ν•œλ²ˆμ— μš”μ²­ν•˜κ±°λ‚˜ λ°±μ—μ„œ 뷰둜 λ„˜κΈ°κΈ° μœ„ν•œ 객체둜, ν•„μš”ν•˜λ‹€λ©΄ λ‹€μŒκ³Ό 같이 μ„ μ–Έν•œλ‹€.

 

 

 

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageRequestDTO {

//    κΈ°λ³Έκ°’: null이면 1νŽ˜μ΄μ§€
    @Builder.Default
    private int page = 1;

//    κΈ°λ³Έκ°’: null이면 10κ°œμ”©
    @Builder.Default
    private int size = 10;

//    검색 μΉ΄ν…Œκ³ λ¦¬
    private String type;

//    검색 ν‚€μ›Œλ“œ
    private String keyword;

    private String link;

    public Pageable getPageable(String...props){
        return PageRequest.of(this.page -1, this.size, Sort.by(props).descending());
    }

    public String[] getTypes(){
        if(type == null || type.isEmpty()){
            return null;
        }

        return type.split("");
    }
}

 

 

@Getter
@ToString
public class PageResponseDTO<E> {

    private int page;

//    νŽ˜μ΄μ§€λ‹Ή 개수 μš”μ²­ λ”°λ‘œ μ—†μœΌλ©΄ 10κ°œμ”©
    @Builder.Default
    private int size = 10;

    private int total;

//    μ‹œμž‘ νŽ˜μ΄μ§€
    private int start;

//    끝
    private int end;

    private boolean prev;
    private boolean next;

    private List<E> dtoList;

//    PageResponseDTO.<Member>withAll 둜 μ‚¬μš©ν•  수 있음
    @Builder(builderMethodName = "withAll")
    public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total){

        if(total <= 0){
            return;
        }

        this.page = pageRequestDTO.getPage();
        this.size = pageRequestDTO.getSize();

        this.total = total;
        this.dtoList = dtoList;

//        ν™”λ©΄μ—μ„œ  <  1 2 3 4 5 6 7 8 9 10 > μ΄λ ‡κ²Œ λ³΄μ—¬μ£Όλ‹ˆκΉŒ endλŠ” λ§ˆμ§€λ§‰ 좜λ ₯될 νŽ˜μ΄μ§€ λ²„νŠΌ, startλŠ” 처음 좜λ ₯될 νŽ˜μ΄μ§€ λ²„νŠΌ
        this.end = (int)(Math.ceil(this.page / 10.0)) * 10;
        this.start = this.end - 9;

//        λ§ˆμ§€λ§‰ νŽ˜μ΄μ§€ λ²„νŠΌ
        int last = (int)(Math.ceil((total/(double)size)));

        this.end = Math.min(end, last);
        this.prev = this.start > 1;
        this.next = total > this.end * this.size;

    }
}

 

 

 

이제 전달받은 `pageResponseDTO`λ₯Ό μ‚¬μš©ν•΄ 화면을 λ‹€μŒκ³Ό 같이 μž‘μ„±ν•  수 μžˆλ‹€.

 

 

<div style="display: inline-flex">총 [[${pageResponseDTO.total}]]λͺ…</div>
<div th:each="member : ${pageResponseDTO.getDtoList()}" th:with="admin=${admin}">
    <img class="rounded-circle" th:src="@{/img/{fileName}(fileName=${member.getFileName()})}">

    <div style="display: inline-flex" th:text="${member.name}"></div>
    <div style="display: inline-flex" th:text="${#temporals.format(member.regDate, 'yy-MM-dd')}"></div>

    <div class="float-end">
        <button style="display: inline-flex" class="btn btn-secondary" th:data-name="${member.id}" th:onclick="adminUpdate(this.getAttribute('data-name'))">μˆ˜μ •</button>
        <button style="display: inline-flex" class="btn btn-danger" th:data-name="${member.id}" th:onclick="admindelete(this.getAttribute('data-name'))">μ‚­μ œ</button>
    </div>

</div>



<div class="">
    <ul class="pagination flex-wrap">
        <li class="page-item" th:if="${pageResponseDTO.prev}">
        	<a class="page-link" th:data-num="${pageResponseDTO.start -1}">이전</a>
        </li>

        <th:block th:each="i : ${#numbers.sequence(pageResponseDTO.start, pageResponseDTO.end)}">
            <li th:class="${pageResponseDTO.page == i} ? 'page-item active' : 'page-item'">
            	<a class="page-link" th:data-num="${i}" >[[${i}]]</a>
            </li>
        </th:block>

        <li class="page-item" th:if="${pageResponseDTO.next}">
        	<a class="page-link" th:data-num="${pageResponseDTO.end + 1}">λ‹€μŒ</a>
        </li>

    </ul>
</div>

 

 

 

 

검색 μ‹œμ—λŠ” `pageRequestDTO` λ₯Ό 전달할 수 μžˆλ„λ‘ form νƒœκ·Έλ₯Ό λ‹€μŒκ³Ό 같이 μΆ”κ°€ν•œλ‹€.

 

 

<form action="/member" method="get">

    <input type="hidden" name="size" th:value="${pageRequestDTO.size}">
    <div class="input-group">
        <div class="input-group-prepend">
            <select class="form-select type" name="type">
                <option value="">---</option>
                <option th:selected="${pageRequestDTO.type == 'n'}" value="n">이름</option>
                <option th:selected="${pageRequestDTO.type == 'i'}" value="i">아이디</option>
                <option th:selected="${pageRequestDTO.type == 'ni'}" value="ni">이름/아이디</option>
            </select>
        </div>

        <input type="text" class="form-control keyword" name="keyword" th:value="${pageRequestDTO.keyword}">

        <div class="input-group-append">
            <button type="submit" class="btn btn-outline-primary searchBtn">검색</button>
            <button type="button" class="btn btn-outline-secondary clearBtn">μ΄ˆκΈ°ν™”</button>
        </div>

    </div>

</form>

 

 

 

 

검색을 μ‹€ν–‰ν•˜λ©΄ `/member?size=10&type=n&keyword=μ•ˆλ…•` url둜 μ΄λ™ν•œλ‹€. type, keywordκ°€ μ‘΄μž¬ν•˜κΈ° λ•Œλ¬Έμ— 검색 쑰건이 적용된 νŽ˜μ΄μ§€κ°€ μ²˜λ¦¬λ˜μ–΄ 뷰에 λ°˜ν™˜λœλ‹€.

 

 

πŸ“Œμ°Έκ³ 

μžλ°” μ›Ή 개발 μ›Œν¬λΆ - κ΅¬λ©κ°€κ²Œ 코딩단

 

Page (Spring Data Core 3.2.3 API)

getTotalPages int getTotalPages() Returns the number of total pages. Returns: the number of total pages

docs.spring.io

 

Pageable (Spring Data Core 3.2.3 API)

isUnpaged default boolean isUnpaged() Returns whether the current Pageable does not contain pagination information. Returns:

docs.spring.io