gi_dor

다시 돌아온 페이징처리 SpringBoot + MyBatis // Pageable XX 본문

Back_End/SpringBoot

다시 돌아온 페이징처리 SpringBoot + MyBatis // Pageable XX

기돌 2026. 2. 23. 23:13
RentCompleteSearchDTO 이름을 RentSearchConditionDTO로 변경해야하는데 수정할곳이 많아서 그냥두기로함




1. 검색 조건을 담을 DTO 

HTML 에서 Controller 로 검색 조건들을 전달하기 위해 필요하다

@Getter
@Setter
public class RentCompleteSearchDTO {
    private String sggCd;       // 시군구 코드
    private String umdNm;       // 법정동 이름
    private String name;        // 건물명
    private Integer minDeposit; // 최소 보증금
    private Integer maxDeposit; // 최대 보증금
    private Integer minRent;    // 최소 월세
    private Integer maxRent;    // 최대 월세
}

 

2. 페이징 계산 처리해줄 DTO

총 데이터 갯수와 , 현재 페이지 번호를 알려주면 SQL 쿼리와 UI 에 필요한 모든 값 ( offset , beginPage, endPage 등등)
자동 계산해준다 

package com.realestate.rent_insight.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class RentPaginationDTO {

    //  페이징 계산 담당일찐


    // 입력 받는 값
    private int totalRows;    // DB내의 전체 데이터        -- 쿼리타면 여기에 갯수 들어감
    private int currentPage;  // 사용자가 요청한 페이지 번호 --  URL의 ?page=1


    // 기본 설정 값
    private int rows = 30;    // 페이지당  계약건수 갯수
    private int pages = 5;    // 페이지네이션 바에 보여줄 페이지 갯수 (1 2 3 4 5)


    // 계산되어야하는 값
    private int totalPages;
    private int totalBlocks;
    private int currentBlock;   // 현재 페이지가 속한 블록 번호
    private int beginPage;      // 현재 블록 시작 페이지
    private int endPage;        // 현재 블록 끝 페이지

    private int offset;         // SQL 쿼리에 사용할 offset 값

    // 현재  첫번째 페이지 ,마지막 페이지 구분
    private boolean isFirst;
    private boolean isLast;

    public RentPaginationDTO() {
    }

    public RentPaginationDTO(int currentPage, int totalRows) {
        this.currentPage = currentPage;
        this.totalRows = totalRows;
        // 객체가 생성되면서 모든 페이징 값 계산 해버리기
        init();
    }

    private void init() {

        // 글이 존재하는지
        if (totalRows > 0) {
            // 전체 페이지 갯수 계산하기
            totalPages = (int) Math.ceil((double) totalRows / rows);    // 3000개 데이터 /  30행 = totalPages= 100개

            // (현재페이지 번호 -1) * 한 페이지당 보여줄 데이터
            offset = (currentPage - 1) * rows;

            // 페이징 바 계산기
            totalBlocks = (int)Math.ceil((double) totalPages / pages);
            currentBlock = (int) Math.ceil((double) currentPage / pages);
            beginPage = (currentBlock - 1) * pages + 1;
            endPage = currentBlock * pages;

            // 마지막 블럭의 끝 페이지 번호 - 총 12페이지일 때 3번째 블록(11, 12)의 끝 페이지는 15가 아니라 12가 되어야 함
            if (currentBlock == totalBlocks) {
                endPage = totalPages;
            }
            // 혹시 모를 상황 대비: endPage가 totalPages를 넘지 않도록
            if (endPage > totalPages) {
                endPage = totalPages;
            }
            isFirst = (currentPage == 1);
            isLast = (currentPage == totalPages);
        }
    }


}

 

 

3. 최종결과를 담아줄 DTO

화면으로 보낼 결과물은 데이터목록 + 페이징 정보 두가지다 
이것을 처리할 DTO

package com.realestate.rent_insight.dto;

import lombok.Getter;

import java.util.List;

@Getter
public class RentPaginationResultDTO<T> {

    private List<T> list;
    private RentPaginationDTO rentPaginationDTO;        // init() 계산끝난 페이징 정보객체

    public RentPaginationResultDTO(List<T> list, RentPaginationDTO rentPaginationDTO) {
        this.list = list;
        this.rentPaginationDTO = rentPaginationDTO;
    }
}

 

4. 데이터베이스에 실제 SQL 명력을 내릴 Mapper

@Param : 여러 개의 파라미터를 XML에 전달할 때 각각의 파라미터에 이름표 를 붙여준다

package com.realestate.rent_insight.domain.mapper;

import com.realestate.rent_insight.domain.entity.RentComplete;
import com.realestate.rent_insight.dto.RentCompleteSearchDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

/**
 * resources/mappers/RentCompleteMapper.xml 파일에 정의된 SQL 쿼리 연결
 */
@Mapper
public interface RentCompleteMapper {

    List<RentComplete> findByComplexConditions(
            @Param("searchDto") RentCompleteSearchDTO searchDto,
            @Param("limit") int limit,
            @Param("offset") int offset);


    int countByComplexConditions(@Param("searchDto") RentCompleteSearchDTO searchDto);
}

 

5. 실제 실행될 SQL 쿼리 작성

  • <sql> 태그 를 사용하여 중복되는 WHERE 절 분리 (사실 같은거 길어서 꼴보기 싫었음)
  • <if> 태그 검색 조건 (searchDto) 에 값이 있을때만 AND 조건 동적으로 추가한다
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.realestate.rent_insight.domain.mapper.RentCompleteMapper">

    <select id="findByComplexConditions"
            resultType="com.realestate.rent_insight.domain.entity.RentComplete">
        SELECT
            id,                             -- 계약 고유번호
            contract_date AS contractDate,  -- 계약일
            sgg_cd AS sggCd,                -- 시군구 코드
            sgg_nm AS sggNm,                -- 시군구 이름
            umd_nm AS umdNm,                -- 법정동 이름
            jibun,                          -- 지번 주소
            name,                           -- 오피스텔 단지명
            deposit,                        -- 보증금
            monthly_rent AS monthlyRent,    -- 월세
            area,                           -- 전용 면적
            build_year AS buildYear,        -- 건축 년도
            floor,                          -- 층수
            contract_term AS contractTerm,  -- 계약 기간
            contract_type AS contractType   -- 계약 종류 (신규/갱신)
        FROM
            rent_complete
        <include refid="whereCondition"></include>
        ORDER BY contract_date DESC
        LIMIT #{limit} OFFSET #{offset}
    </select>

    <sql id="whereCondition">
        <where>
            <if test="searchDto.sggCd != null and searchDto.sggCd != ''">
                AND sgg_cd = SUBSTRING(#{searchDto.sggCd}, 1, 5)
            </if>
            <if test="searchDto.umdNm != null and searchDto.umdNm != ''">
                AND umd_nm = #{searchDto.umdNm}
            </if>
            <if test="searchDto.name != null and searchDto.name != ''">
                AND name LIKE CONCAT('%', #{searchDto.name}, '%')
            </if>
            <if test="searchDto.minDeposit != null and searchDto.maxDeposit != null">
                AND deposit BETWEEN #{searchDto.minDeposit} AND #{searchDto.maxDeposit}
            </if>
            <if test="searchDto.minRent != null and searchDto.maxRent != null">
                AND monthly_rent BETWEEN #{searchDto.minRent} AND #{searchDto.maxRent}
            </if>
        </where>
    </sql>

    <select id="countByComplexConditions" resultType="int">
        SELECT COUNT(*)
        FROM rent_complete
        <include refid="whereCondition" />
    </select>

</mapper>

 

 

6. 비즈니스 로직 처리 - 페이징 처리 모든 과정을 순서대로 사용한다고 보면된다

package com.realestate.rent_insight.service;

import com.realestate.rent_insight.domain.entity.RentComplete;
import com.realestate.rent_insight.domain.mapper.RentCompleteMapper;
import com.realestate.rent_insight.dto.RentCompleteSearchDTO;
import com.realestate.rent_insight.dto.RentPaginationDTO;
import com.realestate.rent_insight.dto.RentPaginationResultDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * 전월세 계약 정보를 검색기능
 * MyBatis 매퍼를 사용하여 복합적인 조건의 검색
 */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 기본적으로 읽기 전용 트랜잭션으로 실행
public class RentCompleteSearchService {

    private final RentCompleteMapper rentCompleteMapper;

    public RentPaginationResultDTO<RentComplete> searchRent(RentCompleteSearchDTO rentCompleteSearchDTO, int page) {
        int totalRows = rentCompleteMapper.countByComplexConditions(rentCompleteSearchDTO);
        RentPaginationDTO rentPaginationDTO = new RentPaginationDTO(page,totalRows);

        List<RentComplete> list = rentCompleteMapper.findByComplexConditions(
                rentCompleteSearchDTO,
                rentPaginationDTO.getRows(),
                rentPaginationDTO.getOffset()
        );
        return new RentPaginationResultDTO<>(list, rentPaginationDTO);
    }


}

 

7. 웹에서 사용자가 요청을 주면 , 서비스에게 일시키고 그 결과를 화면에 전달만 하는 컨트롤러

최종결과를 Model 에 담아 HTML 파일에 보냄

package com.realestate.rent_insight.controller;

import com.realestate.rent_insight.domain.entity.DataUpdateLog;
import com.realestate.rent_insight.domain.entity.Region;
import com.realestate.rent_insight.domain.entity.RentComplete;
import com.realestate.rent_insight.domain.repository.DataUpdateLogRepository;
import com.realestate.rent_insight.dto.RentCompleteSearchDTO;
import com.realestate.rent_insight.dto.RentPaginationResultDTO;
import com.realestate.rent_insight.service.RegionService;//import com.realestate.rent_insight.service.RentSearchService;

import com.realestate.rent_insight.service.RentCompleteSearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;
import java.util.Optional;

@Controller
@RequiredArgsConstructor
@RequestMapping("/rent")
public class RentSearchController {

    private final RentCompleteSearchService rentCompleteSearchService;
    private final RegionService regionService;
    private final DataUpdateLogRepository dataUpdateLogRepository;

    @GetMapping("/search")
    public String searchForm(@ModelAttribute("searchDto") RentCompleteSearchDTO rentCompleteSearchDTO,
                             @RequestParam(value ="page" , defaultValue = "1") int page,
                             Model model) {
        // 1. RegionService를 사용하여 시군구 목록(코드, 이름 포함)을 조회
        List<Region> sigunguList = regionService.getSigunguList();
        model.addAttribute("sigunguList", sigunguList);

        // 2. 서비스 계층을 호출하여 검색 조건에 맞는 데이터를 조회
        RentPaginationResultDTO rentSearchResult = rentCompleteSearchService.searchRent(rentCompleteSearchDTO,page);
        model.addAttribute("rentSearchResult", rentSearchResult);

        // 3. SUCCESS 로그 조회
        Optional<DataUpdateLog> lastLog = dataUpdateLogRepository.findFirstByStatusOrderByIdDesc("SUCCESS");
        if(lastLog.isPresent()) {
            DataUpdateLog log = lastLog.get();
            model.addAttribute("lastLog", log.getCompletionTime());
        }

        // 4. 뷰
        return "rent/search";
    }

}

 

<!-- 페이지네이션 UI -->
<div class="card-footer bg-white" th:if="${rentSearchResult.rentPaginationDTO.totalRows > 0}">
    <nav aria-label="Page navigation">
        <ul class="pagination justify-content-center mb-0">
            <!-- '처음' 버튼 -->
            <li class="page-item" th:classappend="${rentSearchResult.rentPaginationDTO.first} ? 'disabled'">
                <a th:if="${!rentSearchResult.rentPaginationDTO.first}" class="page-link" th:href="@{/rent/search(page=1, sggCd=${searchDto.sggCd}, umdNm=${searchDto.umdNm}, name=${searchDto.name}, minDeposit=${searchDto.minDeposit}, maxDeposit=${searchDto.maxDeposit}, minRent=${searchDto.minRent}, maxRent=${searchDto.maxRent})}">&laquo;</a>
                <span th:if="${rentSearchResult.rentPaginationDTO.first}" class="page-link">&laquo;</span>
            </li>
            <!-- '이전' 버튼 -->
            <li class="page-item" th:classappend="${rentSearchResult.rentPaginationDTO.currentPage == 1} ? 'disabled'">
                <a th:if="${rentSearchResult.rentPaginationDTO.currentPage > 1}" class="page-link" th:href="@{/rent/search(page=${rentSearchResult.rentPaginationDTO.currentPage - 1}, sggCd=${searchDto.sggCd}, umdNm=${searchDto.umdNm}, name=${searchDto.name}, minDeposit=${searchDto.minDeposit}, maxDeposit=${searchDto.maxDeposit}, minRent=${searchDto.minRent}, maxRent=${searchDto.maxRent})}">&lt;</a>
                <span th:if="${rentSearchResult.rentPaginationDTO.currentPage == 1}" class="page-link">&lt;</span>
            </li>
            <!-- 페이지 번호 버튼 -->
            <li th:each="pageNumber : ${#numbers.sequence(rentSearchResult.rentPaginationDTO.beginPage, rentSearchResult.rentPaginationDTO.endPage)}" class="page-item" th:classappend="${pageNumber == rentSearchResult.rentPaginationDTO.currentPage} ? 'active'">
                <a class="page-link" th:href="@{/rent/search(page=${pageNumber}, sggCd=${searchDto.sggCd}, umdNm=${searchDto.umdNm}, name=${searchDto.name}, minDeposit=${searchDto.minDeposit}, maxDeposit=${searchDto.maxDeposit}, minRent=${searchDto.minRent}, maxRent=${searchDto.maxRent})}" th:text="${pageNumber}">1</a>
            </li>
            <!-- '다음' 버튼 -->
            <li class="page-item" th:classappend="${rentSearchResult.rentPaginationDTO.currentPage == rentSearchResult.rentPaginationDTO.totalPages} ? 'disabled'">
                <a th:if="${rentSearchResult.rentPaginationDTO.currentPage < rentSearchResult.rentPaginationDTO.totalPages}" class="page-link" th:href="@{/rent/search(page=${rentSearchResult.rentPaginationDTO.currentPage + 1}, sggCd=${searchDto.sggCd}, umdNm=${searchDto.umdNm}, name=${searchDto.name}, minDeposit=${searchDto.minDeposit}, maxDeposit=${searchDto.maxDeposit}, minRent=${searchDto.minRent}, maxRent=${searchDto.maxRent})}">&gt;</a>
                <span th:if="${rentSearchResult.rentPaginationDTO.currentPage == rentSearchResult.rentPaginationDTO.totalPages}" class="page-link">&gt;</span>
            </li>
            <!-- '마지막' 버튼 -->
            <li class="page-item" th:classappend="${rentSearchResult.rentPaginationDTO.last} ? 'disabled'">
                <a th:if="${!rentSearchResult.rentPaginationDTO.last}" class="page-link" th:href="@{/rent/search(page=${rentSearchResult.rentPaginationDTO.totalPages}, sggCd=${searchDto.sggCd}, umdNm=${searchDto.umdNm}, name=${searchDto.name}, minDeposit=${searchDto.minDeposit}, maxDeposit=${searchDto.maxDeposit}, minRent=${searchDto.minRent}, maxRent=${searchDto.maxRent})}">&raquo;</a>
                <span th:if="${rentSearchResult.rentPaginationDTO.last}" class="page-link">&raquo;</span>
            </li>
        </ul>
    </nav>
</div>
728x90