IT/development

[springBoot/thymeleaf] ajax 페이지네이션 sample(feat. study용)

알 수 없는 사용자 2023. 6. 4.

ddl(Oracle)

/* 게시판 */
CREATE TABLE "BOARD_INFO"
   ("BOARD_NO" NUMBER NOT NULL ENABLE,
	"TITLE" VARCHAR2(50) NOT NULL ENABLE, 
	"CONTENT" VARCHAR2(4000) NOT NULL ENABLE, 
	"USER_ID" VARCHAR2(20) NOT NULL ENABLE,
	"USE_YN" CHAR(1) DEFAULT 'Y' NOT NULL ENABLE, 
	"REG_DATE" DATE DEFAULT SYSDATE NOT NULL ENABLE, 
	"MOD_DATE" DATE, 
	 CONSTRAINT "BOARD_INFO_PK" PRIMARY KEY ("BOARD_NO")
); 
COMMENT ON COLUMN BOARD_INFO.BOARD_NO IS '게시글 순번(시퀀스: BOARD_NO_SEQ)';
COMMENT ON COLUMN BOARD_INFO.TITLE IS '제목';
COMMENT ON COLUMN BOARD_INFO.CONTENT IS '내용';
COMMENT ON COLUMN BOARD_INFO.USER_ID IS '작성자 아이디';
COMMENT ON COLUMN BOARD_INFO.USE_YN IS '사용여부';
COMMENT ON COLUMN BOARD_INFO.REG_DATE IS '등록일';
COMMENT ON COLUMN BOARD_INFO.MOD_DATE IS '수정일';
--시퀀스
CREATE SEQUENCE BOARD_NO_SEQ INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999999 CYCLE NOCACHE ORDER ;

Vo

package study.thboard2.domain.vo;

import lombok.Data;

import javax.validation.constraints.NotBlank;

@Data
//게시판 관련 vo
public class BoardVo extends CommonVo{
    
    private Integer no;                             //게시글 rownum
    private Integer boardNo;                       //게시글 순번(시퀀스)
    
    @NotBlank(message = "제목은 꼭 입력해야 해")
    private String title;                       //제목

    private String userId;                      //작성자

    @NotBlank(message = "내용도 반드시 알아야겠지?")
    private String content;                     //내용

    private char useYn;                         //사용여부
}
package study.thboard2.domain.vo;

import lombok.Data;

@Data
//공통 vo
public class CommonVo {
    /*검색 필드*/
    private String type;                              //검색 타입
    private String keyword;                           //검색 키워드
    
    /* 페이지네이션 필드 */
    private int currentPage = 1;                          //현재 페이지 번호
    private int totalCount;                           //전체 게시글 개수
    private int firstRecordIndex, lastRecordIndex;    // 페이징 SQL에서 사용하는 첫번 째, 마지막 인덱스
    
    /* 날짜 필드 */
    private String regDate;                           //등록일
    private String modDate;                           //수정일
}

xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://www.mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="study.thboard2.mapper.BoardMapper">

<!-- 검색 parameter값 -->
<sql id="appendSearch">
    <!-- 제목 -->
    <if test="type == 'T'.toString()">
        AND TITLE LIKE '%' || #{keyword} || '%'
    </if>
    <!-- 내용 -->
    <if test="type == 'C'.toString()">
        AND CONTENT LIKE '%' || #{keyword} || '%'
    </if>
    <!-- 작성자 -->
    <if test="type == 'W'.toString()">
        AND USER_ID LIKE '%' || #{keyword} || '%'
    </if>
</sql>

    <!--게시글 목록 조회-->
    <select id="selectBoardList" parameterType="CommonVo" resultType="BoardVo">
        /* 게시글 목록 조회 */
         SELECT T2.no
              , T2.BOARD_NO AS boardNo
              , T2.TITLE AS title
              , T2.CONTENT AS content
              , T2.USER_ID AS userId
              , T2.USE_YN AS userYn
              , TO_CHAR(T2.REG_DATE, 'YYYY-MM-DD') AS regDate
              , NVL(TO_CHAR(T2.MOD_DATE, 'YYYY-MM-DD'),'수정사항 없음') AS modDate
           FROM
               (
                    SELECT ROWNUM AS no
                         , T1.BOARD_NO
                         , T1.TITLE
                         , T1.CONTENT
                         , T1.USER_ID
                         , T1.USE_YN
                         , T1.REG_DATE
                         , T1.MOD_DATE
                      FROM
                              ( SELECT BOARD_NO
                                     , TITLE
                                     , CONTENT
                                     , USER_ID
                                     , USE_YN
                                     , REG_DATE
                                     , MOD_DATE
                                  FROM BOARD_INFO
                                 WHERE 1=1
                                   AND USE_YN = 'Y'
                                <include refid="appendSearch"/>
                                 ORDER BY REG_DATE DESC ) T1
           <![CDATA[ WHERE ROWNUM <= #{lastRecordIndex} ]]>) T2
         WHERE T2.no >= #{firstRecordIndex}
    </select>

    <!--게시글 전체 카운트-->
    <select id="selectBoardCnt" parameterType="CommonVo" resultType="int">
        SELECT COUNT(*)
          FROM BOARD_INFO
         <where>
         <include refid="appendSearch"/>
         </where>
    </select>
</mapper>

mapper

package study.thboard2.mapper;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
import study.thboard2.domain.vo.BoardVo;
import study.thboard2.domain.vo.CommonVo;

import java.util.List;

@Repository @Mapper
public interface BoardMapper {

    /*게시글 목록 조회*/
    List<BoardVo> selectBoardList(CommonVo commonVo);

    /*게시글 전체 카운트*/
    int selectBoardCnt(CommonVo commonVo);
}

service

package study.thboard2.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;
import study.thboard2.domain.vo.BoardVo;
import study.thboard2.domain.vo.CommonVo;
import study.thboard2.domain.vo.PaginationInfo;
import study.thboard2.mapper.BoardMapper;

import java.util.List;

import static study.thboard2.common.utils.ValidationUtil.invokeErrors;

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardService extends CommonService{

    private final BoardMapper boardMapper;

    /**
     * 게시글 목록 조회
     * @param commonVo
     * @return
     * @throws Exception
     */
    public List<BoardVo> getBoardList(CommonVo commonVo) throws Exception{
        return boardMapper.selectBoardList(commonVo);
    }

    /**
     * 게시글 전체 카운트
     * @param commonVo
     * @return
     */
    public int getBoardCnt(CommonVo commonVo) throws Exception{
        return boardMapper.selectBoardCnt(commonVo);
    }
}
package study.thboard2.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import study.thboard2.domain.vo.CommonVo;
import study.thboard2.domain.vo.PaginationInfo;

@Service
@Slf4j
//공통 서비스
public class CommonService {

    /**
     * 페이지네이션 처리 후 페이지네이션 객체 반환
     * @param commonVo
     * @return
     */
    public PaginationInfo getPaginationInfo(CommonVo commonVo) {
        PaginationInfo paging = new PaginationInfo();
        paging.setTotalCount(commonVo.getTotalCount());
        paging.setCurrentPage(commonVo.getCurrentPage());
        return paging;
    }
    
}

pagination

package study.thboard2.domain.vo;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@ToString
public class PaginationInfo {

 // 페이지네이션 처리를 위한 parameter값
    @Getter @Setter
    private int currentPage = 1;            // 현재 페이지 번호 (초기값 : 1)
    @Getter @Setter
    private int totalCount; 		        // 전체 목록 건수 (목록)
    @Getter @Setter
    private int recordCountPerPage = 10;	// 페이지당 목록 건수 (목록)
    @Getter @Setter
    private int pageSize = 10; 		        // 페이징 목록에 게시되는 페이지 건수 (페이징)

// 페이지네이션 계산된 값(외부에서 값이 변경되면 안되기에 @getter, @setter 미설정)    
    private int totalPageCount;		        // 전체 페이지 건수 (페이징)
    private int firstPageNo; 		        // 페이지 목록의 첫 페이지 번호 (페이징)
    private int lastPageNo; 		        // 페이지 목록의 마지막 페이지 번호 (페이징)
    private int firstRecordIndex;	        // 페이징 SQL의 조건절에 사용되는 시작 Index (SQL)
    private int lastRecordIndex;	        // 페이징 SQL의 조건절에 사용되는 마지막 Index (SQL)


    public int getTotalPageCount() {
        totalPageCount = ((getTotalCount() - 1) / getRecordCountPerPage()) + 1;
        return totalPageCount;
    }

    public int getFirstPageNo() {
        firstPageNo = ((getCurrentPage() - 1) / getPageSize()) * getPageSize() + 1;
        return firstPageNo;
    }

    public int getLastPageNo() {
        lastPageNo = getFirstPageNo() + getPageSize() - 1;
        if(lastPageNo > getTotalPageCount()) {
            lastPageNo = getTotalPageCount();
        }
        return lastPageNo;
    }

    public int getFirstRecordIndex() {
        firstRecordIndex = (getCurrentPage() - 1) * getRecordCountPerPage() + 1;
        return firstRecordIndex;
    }

    public int getLastRecordIndex() {
        lastRecordIndex = getCurrentPage() * getRecordCountPerPage();
        return lastRecordIndex;
    }

    public int getFirstPage() {
        return 1;
    }
    public int getLastPage() {
        return getTotalPageCount();
    }

}

controller

package study.thboard2.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import study.thboard2.domain.vo.*;
import study.thboard2.service.BoardService;
import study.thboard2.service.FileService;
import study.thboard2.service.ReplyService;

import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Controller
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/")
public class BoardController extends CommonController{

    private final BoardService boardService;
    private final ReplyService replyService;
    private final FileService fileService;

    
     /**
     * 게시글 목록(ajax)
     * @param commonVo
     * @return
     */
    @PostMapping("listAjax")
    @ResponseBody
    public ResponseEntity<?> listAjax(@ModelAttribute("commonVo") CommonVo commonVo) throws Exception {
        ModelAndView mv = new ModelAndView("pages/main");

        Map<String, Object> map = new HashMap<>();
        //전체 게시글 수
        int totalCnt = boardService.getBoardCnt(commonVo);
        commonVo.setTotalCount(totalCnt);
        //페이징 처리 후 반환 객체
        PaginationInfo paging = boardService.getPaginationInfo(commonVo);
        log.info("commonVo = [{}]", commonVo);
        log.info("paging = [{}]", paging);
        commonVo.setFirstRecordIndex(paging.getFirstRecordIndex());
        commonVo.setLastRecordIndex(paging.getLastRecordIndex());

        List<BoardVo> boardList = boardService.getBoardList(commonVo);

        map.put("list", boardList);
        map.put("paging", paging);
        return new ResponseEntity<>(map, HttpStatus.OK);
    }
}

main.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/default_layout}">

<!-- Content -->
<div layout:fragment="content">
    <main>
        <div class="container-fluid px-4">
            <h1 class="mt-4">게시글 목록</h1>
                <form class="d-none d-md-inline-block form-inline ms-auto me-0 me-md-3 my-2 my-md-0" method="get">
                <div class="input-group">
                    <select name="type" id="type" th:field="${search.type}">
                        <option value="">선택해라</option>
                        <option value="W">작성자</option>
                        <option value="T">제목</option>
                        <option value="C">내용</option>
                    </select>
                    <input class="form-control" th:field="${search.keyword}" type="text" id="keyword" name="keyword" placeholder="검색어를 입력해라." aria-label="Search for..." aria-describedby="btnNavbarSearch" />
                    <button class="btn btn-primary" id="btnSearch" type="button"><i class="fas fa-search"></i></button>
                </div>
            </form>
                <div class="card mb-4">
                    <div class="card-header">
                        <i class="fas fa-table me-1"></i>
                        게시글이야.
                    </div>
                    <form id="frm" action="/del" method="post">
                    <div class="card-body">
                        <table id="boardList" class="table table-striped">
                            <thead>
                                <tr>
                                    <th>순번</th>
                                    <th>제목</th>
                                    <th>내용</th>
                                    <th>작성자</th>
                                    <th>등록일</th>
                                    <th>수정일</th>
                                    <th></th>
                                </tr>
                            </thead>
                            <!-- 동적 tbody -->
                            <tbody id="boardBody"></tbody>
                        </table>
                    </div>
                    <!-- 동적 페이지네이션 -->
                    <nav id="pagiNav" aria-label="Page navigation"></nav>
                    </form>
                </div>
            <div class="input-group">
                <a th:href="@{/reg}" class="btn btn-success">등록</a>
            </div>
        </div>
    </main>

    <script layout:fragment="script" th:inline="javascript" type="text/javascript">

        const defaultParam = { type: "", keyword: ""};
        let searchParam = defaultParam;

        $(document).ready(function () {

            $("#btnSearch").on("click", function (e) {
                e.preventDefault();
                if ($("#type option:selected").val() === "") {
                        alert("키워드를 선택하라니까?");
                        return;
                    } else {
                        console.log("검색한다?");
                        searchParam = {type: $("#type").val(), keyword: $("#keyword").val() }
                        getList(searchParam, 1);
                }
            });
            //초기 목록은 1페이지로 고정
            getList(defaultParam, 1);
        });

        //페이징
        function movePage(pageNum) {
            getList(searchParam, pageNum);
        }

        //게시글 목록
        function getList(params, currentPage) {
            $.ajax({
                url : '/listAjax?currentPage=' + currentPage,
                type : 'post',
                data: params,
                success: function (result) {
                    let boardHtml = "";

                    let list = result.list;
                    let paging = result.paging;
                    //테이블 draw
                    for (let i = 0; i < list.length; i++) {
                            boardHtml += "<tr>";
                            boardHtml += "<td>" + list[i].no;
                            boardHtml += "</td>";
                            boardHtml += "<td>" + list[i].title;
                            boardHtml += "</td>";
                            boardHtml += "<td>" + list[i].content;
                            boardHtml += "</td>";
                            boardHtml += "<td>" + list[i].userId;
                            boardHtml += "</td>";
                            boardHtml += "<td>" + list[i].regDate;
                            boardHtml += "</td>";
                            boardHtml += "<td>" + list[i].modDate;
                            boardHtml += "</td>";
                            boardHtml += "</tr>";
                    }
                    $("#boardBody").html(boardHtml);

                    //페이지네이션 draw
                    let pageHtml = "";

                    pageHtml += "<ul class='pagination justify-content-center'>";

                    if (result.paging.firstPageNo > 1) {
                        const prev = parseInt(result.paging.firstPageNo) - 1;
                        pageHtml += "<li class='page-item'>";
                        pageHtml += "<a href='#' class='page-link' onclick='movePage("+ prev +")' style='cursor: pointer'>Prev</a>";
                        pageHtml += "</li>";
                    }

                    for (let i = result.paging.firstPageNo; i <= result.paging.lastPageNo; i++) {
                        pageHtml += "<li class='page-item'>";

                        if (result.paging.currentPage === i) {
                            pageHtml += "<a class='page-link active' onclick='movePage(" + i + ")' style='cursor: pointer'>" + i + "</a>";
                            pageHtml += "</li>";
                        } else {
                            pageHtml += "<a class='page-link' onclick='movePage(" + i + ")' style='cursor: pointer'>" + i + "</a>";
                            pageHtml += "</li>";
                        }
                    }

                    if (result.paging.lastPageNo < result.paging.totalPageCount) {
                        const next = parseInt(result.paging.lastPageNo) + 1;
                        pageHtml += "<li class='page-item'>";
                        pageHtml += "<a href='#' class='page-link' onclick='movePage("+ next +")' style='cursor: pointer'>Next</a>";
                        pageHtml += "</li>";
                    }
                    pageHtml += "</ul>";    

                    $("#pagiNav").html(pageHtml);


                },
                error: function (request, status, error) {
                    console.log(error);
                }
            });
        }
    </script>

</div>
</html>

default_layout.html

<!DOCTYPE html>
<html lang="ko"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
    <meta name="description" content="" />
    <meta name="author" content="" />
    <title>thymeleaf board test</title>
<!--    <link href="https://cdn.jsdelivr.net/npm/simple-datatables@7.1.2/dist/style.min.css" rel="stylesheet" />-->
    <link href="css/styles.css" rel="stylesheet" /> 
    <!--jquery script-->
    <script src="http://code.jquery.com/jquery-latest.min.js"></script>
    <script src="https://use.fontawesome.com/releases/v6.3.0/js/all.js" crossorigin="anonymous"></script>
</head>

<body class="sb-nav-fixed">
<!-- header fragment 사용 -->
<th:block th:replace="fragments/header :: headerFragment"></th:block>
<div id="layoutSidenav">
    <div id="layoutSidenav_nav">
        <!-- sidebar fragment 사용 -->
        <th:block th:replace="fragments/sidebar :: sidebarFragment"></th:block>
    </div>
    <div id="layoutSidenav_content">
        <main>
            <!-- content fragment 사용 -->
            <th:block layout:fragment="content"></th:block>
            <!-- content script -->
            <th:block layout:fragment="script"></th:block>
        </main>
        <!-- footer fragment 사용 -->
        <th:block th:replace="fragments/footer :: footerFragment"></th:block>
    </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="js/scripts.js"></script>
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.8.0/Chart.min.js" crossorigin="anonymous"></script>-->
<!--<script src="assets/demo/chart-area-demo.js"></script>-->
<!--<script src="assets/demo/chart-bar-demo.js"></script>-->
<!--<script src="https://cdn.jsdelivr.net/npm/simple-datatables@7.1.2/dist/umd/simple-datatables.min.js" crossorigin="anonymous"></script>-->
<!--<script src="js/datatables-simple-demo.js"></script>-->
</body>
</html>

header.html

<html lagn="ko" xmlns:th="http://www.thymeleaf.org">
    <!--headerFragment 선언-->
    <nav th:fragment="headerFragment" class="sb-topnav navbar navbar-expand navbar-dark bg-dark">
        <!-- Navbar Brand-->
            <a class="navbar-brand ps-3" th:href="@{/}">Start Bootstrap</a>
            <!-- Sidebar Toggle-->
            <button class="btn btn-link btn-sm order-1 order-lg-0 me-4 me-lg-0" id="sidebarToggle" href="#!"><i class="fas fa-bars"></i></button>
            <!-- Navbar Search-->
            <form class="d-none d-md-inline-block form-inline ms-auto me-0 me-md-3 my-2 my-md-0" action="/logout" method="post">

            <!-- Navbar-->
            <ul class="navbar-nav ms-auto ms-md-0 me-3 me-lg-4">
                <li class="nav-item dropdown">
                    <th:block th:if="${session.id != null}">
                    <a class="nav-link dropdown-toggle" id="navbarDropdown" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false" th:text="${session.id} + '님 환영합니다.'"><i class="fas fa-user fa-fw"></i></a>
                    <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
                        <li><button type="submit" class="dropdown-item" id="logout">Logout</button></li>
                    </ul>
                    </th:block>
                </li>
            </ul>
            </form>
    </nav>
</html>

sidebar.html

<html lagn="ko" xmlns:th="http://www.thymeleaf.org">
    <!--sidebarFragment 선언-->
    <nav th:fragment="sidebarFragment" class="sb-sidenav accordion sb-sidenav-dark" id="sidenavAccordion">
    <div class="sb-sidenav-menu">
            <div class="nav">
<!--                <div class="sb-sidenav-menu-heading">Interface</div>-->
                <a class="nav-link collapsed" href="#" data-bs-toggle="collapse" data-bs-target="#collapseLayouts" aria-expanded="false" aria-controls="collapseLayouts">
                    <div class="sb-nav-link-icon"><i class="fas fa-columns"></i></div>
                    메뉴
                    <div class="sb-sidenav-collapse-arrow"><i class="fas fa-angle-down"></i></div>
                </a>
                <div class="collapse" id="collapseLayouts" aria-labelledby="headingOne" data-bs-parent="#sidenavAccordion">
                    <nav class="sb-sidenav-menu-nested nav">
                        <a class="nav-link" th:href="@{/}">게시판관리</a>
                    </nav>
                </div>
        </div>
    </nav>
</html>

footer.html

<html lagn="ko" xmlns:th="http://www.thymeleaf.org">
    <!--footerFragment 선언-->
    <footer th:fragment="footerFragment" class="py-4 bg-light mt-auto">
        <div class="container-fluid px-4">
            <div class="d-flex align-items-center justify-content-between small">
                <div class="text-muted">Copyright &copy; Your Website 2022</div>
                <div>
                    <a href="#">Privacy Policy</a>
                    &middot;
                    <a href="#">Terms &amp; Conditions</a>
                </div>
            </div>
        </div>
    </footer>
</html>

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.11'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'study'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.0'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
    implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.oracle.database.jdbc:ojdbc8'
    testCompileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

result

※ 디테일 처리는 되지 않은 기초 샘플 소스

front template은 sb-admin 사용(첨부파일 첨부)

startbootstrap-sb-admin-gh-pages.zip
0.06MB