SpringBoot 게시판 프로젝트

스프링부트(SpringBoot) 게시판 만들기 (12) - 페이징 & 검색 기능 구현

얼뚱인데요 2024. 7. 13. 22:28
페이징 기능 구현하기

 

페이징(Paging)이란?


사용자에게 데이터를 보일 때, 전체 데이터 중 일부만 보여주는 방식이다. 

만약 등록된 게시글이 100,000 개라고 가정할 때, 이 모든 게시글을 한 번에(한 페이지에) 다 출력하려고 하면

로딩 속도도 느려지고, 필요한 게시글을 찾기가 매우 어려워질 것이다.

 

이러한 문제점을 해결하기 위한 기능이 페이징, 검색 기능이다.

더보기

- GPT가 설명해준 게 도움이 돼서 긁어옴 -

페이징은 컴퓨터 메모리 관리 기술 중 하나로, 프로그램이 필요로 하는 데이터를 효율적으로 처리하기 위해 사용됩니다. 이를 아주 쉽게 설명하자면, 큰 책을 읽는 상황을 생각해보세요.

  1. 큰 책과 작은 책장: 집에 큰 책(프로그램 전체) 하나가 있지만, 책장을 작게 만들어서 책의 일부만 올려놓을 수 있다고 상상해보세요. 책장의 공간이 제한되어 있어서 한 번에 책 전체를 올려놓을 수 없습니다.
  2. 필요한 부분만 올리기: 그래서 책을 읽을 때 필요한 페이지(데이터)만 책장에 올려놓고, 나머지 페이지는 옆에 두는 겁니다. 책장에 있는 페이지를 다 읽으면 다시 필요한 페이지를 올려놓고, 다 읽은 페이지는 옆에 두고 또 다른 페이지를 올려놓는 식입니다.
  3. 컴퓨터 메모리와 하드디스크: 컴퓨터에서는 메모리(책장)가 작고, 하드디스크(옆에 둔 페이지들)가 큽니다. 프로그램이 실행될 때 필요한 데이터만 메모리에 올려놓고, 나머지는 하드디스크에 보관합니다. 필요할 때마다 데이터를 메모리로 가져오고, 다 쓴 데이터는 다시 하드디스크로 보내는 과정을 반복합니다.
  4. 효율적인 메모리 사용: 이 과정을 통해 컴퓨터는 메모리를 효율적으로 사용하고, 한 번에 큰 프로그램을 실행할 수 있게 됩니다.

쉽게 말해, 페이징은 작은 책장에 필요한 책 페이지를 올려놓고 읽는 것처럼, 컴퓨터가 메모리를 효율적으로 사용하기 위해 필요한 데이터만 가져오는 방법입니다.

 

 

1) 페이지 파라미터 관리용 클래스 추가

 

페이징과 검색 처리에 필요한 파라미터(데이터)

- page : 현재 페이지 번호를 의미하며, 페이지 정보 계산에 사용된다.

- recordSize : 페이지당 출력할 데이터 개수를 의미하며, page와 함께 페이지 정보 계산에 사용된다.

- pageSize : 화면 하단에 출력할 페이지의 크기를 의미하며, 5로 지정하면 1~5까지, 10으로 지정하면 1~10까지의 페이지를 보인다.

- keyword : 검색 키워드를 의미하며, MyBatis의 동적(Dynamic) SQL 처리에 사용된다.

- searchType : 검색 유형을 의미하며, keyword와 함께 검색처리에 사용된다.

 

뷰(HTML)에서 수집할(전달받을) 파라미터가 많으면 클래스로 따로 관리하는 것이 효율적이다.

 

SearchDto.java

package com.study.common.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class SearchDto {

    private int page;            // 현재 페이지 번호
    private int recordSize;      // 페이지당 출력할 페이지 개수
    private int pageSize;        // 화면 하단에 출력할 페이지 사이즈
    private String keyword;      // 검색 키워드
    private String searchType;   // 검색 유형

    public SearchDto() {
        this.page = 1;
        this.recordSize = 10;
        this.pageSize = 10;
    }

    public int getOffset() {
        return (page - 1) * recordSize;
    }
}

 

생성자를 통해 현재 페이지 번호는 1, 페이지당 출력할 페이지 개수와 하단에 출력할 페이지 사이즈는 10으로 초기화.

 

getOffset() : DB에서 LIMIT 구문의 시작 부분에 사용되는 메서드. 해당 메서드가 사용되는 방식은 SQL 쿼리 작성 후에 기술할 예정

 

 

2) Mapper와 XML Mapper 수정

 

PostMapper의 findAll(), count() 메서드가 SearchDto 타입의 객체들을 파라미터로 받을 수 있도록 코드 변경

/**
 * 게시글 리스트 조회
 * @return 게시글 리스트
 */
List<PostResponse> findAll(SearchDto params);

/**
 * 게시글 수 카운팅
 *
 * @return 게시글 수
 */
int count(SearchDto params);

 

 

XML Mapper의 리스트 조회 쿼리에 파라미터 타입 추가 + LIMIT Offset 사용 그리고 게시글 카운팅 쿼리 새로 추가

<!-- 게시글 리스트 조회 -->
<select id="findAll" parameterType="com.study.common.dto.SearchDto" resultType="com.study.domain.post.PostResponse">
    SELECT
        <include refid="postColumns"></include>
    FROM
        tb_post
    WHERE
        delete_yn = 0
    ORDER BY
        id DESC
    LIMIT #{offset}, #{recordSize}
</select>

<!-- 게시글 수 카운팅 -->
<select id="count" parameterType="com.study.common.dto.SearchDto" resultType="int">
    SELECT
        COUNT(*)
    FROM
        tb_post
    WHERE
        delete_yn = 0
</select>

 

LIMIT 구문은 SELECT 쿼리와 같이 사용되며, 반환되는 데이터 값(record)의 개수를 지정할 수 있다.

 

offset : 데이터베이스 쿼리를 실행할 때, 데이터를 어디서부터 가져올지 결정하는 숫자. 쉽게 말해, 페이지 번호를 사용해 데이터를 나눠서 가져올 때 사용.

recordSize : 시작 위치(offset)를 기준으로 조회할 데이터의 개수를 의미.

예를 들어 우리가 한 페이지에 10개의 데이터씩 보여준다고 가정할 때,

현재 3번째 페이지를 보고 싶으면 앞에 있는 20개의 데이터는 건너뛰고, 21번째 데이터부터 30번째 데이터까지 가져와야한다.  

 

오프셋은 이를 위해서 { (페이지 번호 - 1) * 페이지 크기 } 로 계산된다.

3번째 페이지를 보고 싶다면, (3 - 1) * 10 = 20. 즉, 20개의 데이터를 건너뛰고 21번째 데이터부터 시작된다.

 

SQL 쿼리에서는 LIMIT 이라는 키워드를 사용해 데이터를 제한하고, 오프셋을 사용해 시작 지점을 정한다.

 

우리가 XML Mapper에 선언한 쿼리를 사용해 예시를 들면, 현재 페이지 번호가 3일 땐 " LIMIT 20, 10 " 이라는 쿼리가 실행이 되며 20개의 데이터를 건너뛰고, 그 다음 10개의 데이터를 가져오게(조회) 되는 것이다. 

 

 

 

3) PostService 수정

 

Mapper 와 동일하게 서비스의 findAllPost() 메서드도 SearchDto 타입의 객체를 파라미터로 전달 받도록 변경한다.

/**
 * 게시글 리스트 조회
 * @return 게시글 리스트
 */
public List<PostResponse> findAllPost(final SearchDto params) {
    return postMapper.findAll(params);
}

 

 

 

4) PostController 수정

 

// 게시글 목록 페이지
@GetMapping("/post/list.do")
public String openPostList(@ModelAttribute("params") final SearchDto params, Model model) {
    List<PostResponse> posts = postService.findAllPost(params);
    model.addAttribute("posts", posts);
    return "post/list";
}

 

 

5) 자가 복사(Self Copy) 이용하기

 

페이징 테스트를 위한 대량의 데이터가 필요하니 자가 복사를 이용하자.

 

우선 게시글 테이블을 초기화하는 명령어 실행.

// 테이블의 모든 데이터를 DELETE 하고, AUTO_INCREMENT를 1로 초기화
TRUNCATE tb_post;

 

이후 게시글 1,000개를 등록하는 테스트 코드를 작성한 후 실행한다.

Mapper의 findAll()의 구조가 변경되어 오류가 발생하므로, MapperTest 클래스는 전체 주석처리 한 후에 실행.

 

PostServiceTest.java

@Test
void saveByForeach() {
    for (int i = 1; i <= 1000; i++) {
        PostRequest params = new PostRequest();
        params.setTitle(i + "번 게시글 제목");
        params.setContent(i + "번 게시글 내용");
        params.setWriter("테스터" + i);
        params.setNoticeYn(false);
        postService.savePost(params);
    }
}

 

테스트 성공

 

 

6) 자가 복사(Self Copy) 쿼리 실행하기

INSERT INTO tb_post (title, content, writer, view_cnt, notice_yn, delete_yn)
(SELECT title, content, writer, view_cnt, notice_yn, delete_yn FROM tb_post WHERE delete_yn = 0);

 

sql 콘솔에서 해당 명령어를 실행하면 할때마다 ( 테이블의 전체 데이터 * 2 ) 만큼의 데이터가 증가 된다.

5번 실행한 결과 16,000 row 생성

 

7) 목록 페이지 테스트 해보기

 

① 쿼리스트링이 없는 경우 : 가장 마지막으로 등록된 데이터를 기준으로 10건이 출력

LIMIT 0, 10

 

 

  ② 쿼리스트링이 있는 경우 : URL에 강제로 파라미터(page=5, recordSize=15)를 연결한 후 리스트 페이지로 접근한 결과                                                                          한 페이지에 15개의 게시글이 뜨고 5번째 페이지의 게시글이 출력됨!

                                                     (아직 앞단에 연결하기 전이라 페이지 번호 클릭해도 안먹혀서 쿼리스트링 직접 입력으로 테스트 한 것 ) 

 

LIMIT 60, 15 (5페이지 이므로 (5-1) * 15 =60. 즉, 61번째 게시글부터 출력)

 

8) 페이지네이션(Pagination) 처리용 클래스 추가

 

 앞서 했던 작업들은 LIMIT 쿼리와 함께 간단한 테스트 작업 !

이제는 실질적으로 페이지네이션 기능을 구현하여 화면단에서 사용자가 이용가능하도록 해보자.

 

Pagination.java

package com.study.common.paging;

import com.study.common.dto.SearchDto;
import lombok.Getter;

@Getter
public class Pagination {

    private int totalRecordCount;   // 전체 데이터 수
    private int totalPageCount;     // 전체 페이지 수
    private int startPage;          // 첫 페이지 번호
    private int endPage;            // 끝 페이지 번호
    private int limitStart;         // LIMIT 시작 위치
    private boolean existPrevPage;  // 이전 페이지 존재 여부
    private boolean existNextPage;  // 다음 페이지 존재 여부

    public Pagination(int totalRecordCount, SearchDto params) {
        if (totalRecordCount > 0) {
            this.totalRecordCount = totalRecordCount;
            calculation(params);
        }
    }

    private void calculation(SearchDto params) {

        // 전체 페이지 수 계산
        totalPageCount = ((totalRecordCount - 1) / params.getRecordSize()) + 1;

        // 현재 페이지 번호가 전체 페이지 수보다 큰 경우, 현재 페이지 번호에 전체 페이지 수 저장
        if (params.getPage() > totalPageCount) {
            params.setPage(totalPageCount);
        }

        // 첫 페이지 번호 계산
        startPage = ((params.getPage() - 1) / params.getPageSize()) * params.getPageSize() + 1;

        // 끝 페이지 번호 계산
        endPage = startPage + params.getPageSize() - 1;

        // 끝 페이지가 전체 페이지 수보다 큰 경우, 끝 페이지 전체 페이지 수 저장
        if (endPage > totalPageCount) {
            endPage = totalPageCount;
        }

        // LIMIT 시작 위치 계산
        limitStart = (params.getPage() - 1) * params.getRecordSize();

        // 이전 페이지 존재 여부 확인
        existPrevPage = startPage != 1;

        // 다음 페이지 존재 여부 확인
        existNextPage = (endPage * params.getRecordSize()) < totalRecordCount;
    }
}

 

 

 

9) XML Mapper 수정하기

 

SearchDto의 offset(데이터 불러올 시작 위치)역할을 Pagination의 limitStart로 변경

 

offset 대신 limitStart 넣기

<!-- 게시글 리스트 조회 -->
<select id="findAll" parameterType="com.study.common.dto.SearchDto" resultType="com.study.domain.post.PostResponse">
    SELECT
        <include refid="postColumns" />
    FROM
        tb_post
    WHERE
        delete_yn = 0
    ORDER BY
        id DESC
    LIMIT #{pagination.limitStart}, #{recordSize}
</select>

 

 

 

10) SearchDto 수정하기

 

findAll 쿼리에서는 pagination 객체의 limitStart를 사용하고 있으나,

SearchDto에는 Pagination 타입의 멤버가 없으니 SearchDto가 Pagination 타입의 멤버변수를 갖도록 선언

 

package com.study.common.dto;

import com.study.paging.Pagination;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class SearchDto {

    private int page;            // 현재 페이지 번호
    private int recordSize;      // 페이지당 출력할 데이터 개수
    private int pageSize;        // 화면 하단에 출력할 페이지 사이즈
    private String keyword;      // 검색 키워드
    private String searchType;   // 검색 유형
    private Pagination pagination; // 페이지네이션 정보

    public SearchDto() {
        this.page = 1;
        this.recordSize = 10;
        this.pageSize = 10;
    }
}

 

 

 

 

11) 페이징(Paging) 전용 응답(Response) 클래스 추가

 

PagingResponse.java

package com.study.common.paging;

import lombok.Getter;

import java.util.ArrayList;
import java.util.List;

@Getter
public class PagingResponse<T> {
    
    private List<T> list = new ArrayList<>();
    private Pagination pagination;
    
    public PagingResponse(List<T> list, Pagination pagination) {
        this.list.addAll(list);
        this.pagination = pagination;
    }
}

 

 

페이징 기능을 화면에 보이려면 뷰(HTML) 단에서 이루어져야 한다. 해당 뷰인 list.html은 리스트 데이터와 Pagination 객체 모두를 필요로 한다.

 

하지만 PostService의 findAllPost() 리턴 타입은 List<PostResponse> 라는 것. Pagination 객체를 생성하여 페이지 정보를 계산하는 것까지는 문제가 안됨. 문제는 이 리턴 타입으로는 리스트 데이터와 Pagination 객체 모두를 컨트롤러로 변활할 수 없는 것.

 

key-value 구조로 이루어진 Map에 리스트 데이터와 Pagination 객체를 담아 리턴해주어도 되지만, 직관적인 처리를 위해 페이징 전용 응답 클래스를 생성함. 

 

List<T> : 제네릭을 활용한 것으로 T는 Type을 의미하며, 어떤 타입의 객체던 상관없이 모두 데이터로 받겠다는 의미.

pagination : 계산된 페이지 정보를 해당 변수에 저장

 

 

 

12) 서비스(PostService) 수정하기

 

findAllPost()에 페이지 정보를 계산하는 로직 추가

/**
 * 게시글 리스트 조회
 * @param params - search conditions
 * @return list & pagination information
 */
public PagingResponse<PostResponse> findAllPost(final SearchDto params) {

    // 조건에 해당하는 데이터가 없는 경우, 응답 데이터에 비어있는 리스트와 null을 담아 반환
    int count = postMapper.count(params);
    if (count < 1) {
        return new PagingResponse<>(Collections.emptyList(), null);
    }

    // Pagination 객체를 생성해서 페이지 정보 계산 후 SearchDto 타입의 객체인 params에 계산된 페이지 정보 저장
    Pagination pagination = new Pagination(count, params);
    params.setPagination(pagination);

    // 계산된 페이지 정보의 일부(limitStart, recordSize)를 기준으로 리스트 데이터 조회 후 응답 데이터 반환
    List<PostResponse> list = postMapper.findAll(params);
    return new PagingResponse<>(list, pagination);
}

 

계산된 페이지 정보를 기준으로 findAll 쿼리를 실행하고,

PagingResponse 클래스를 이용해서 리스트 데이터와 계산된 페이지 정보를 함께 리턴

 

 

 

13) 컨트롤러(PostController) 수정하기

 

게시글 목록 뷰로 전달하는 데이터만 변경해주기

// 게시글 목록 페이지
@GetMapping("/post/list.do")
public String openPostList(@ModelAttribute("params") final SearchDto params, Model model) {
    PagingResponse<PostResponse> posts = postService.findAllPost(params);
    model.addAttribute("posts", posts);
    return "post/list";
}

 

 

14) 화면단(list.html) 수정하기

 

Pagination 객체를 이용해서 화면 하단에 페이지 번호를 그리고,

번호를 클릭했을 때 번호에 해당하는 페이지로 이동하는 기능 구현하기

 

타임리프를 이용해서 Html을 그려도 되지만, JS를 이용해서 리스트 데이터와 페이지 번호를 그리는 방법을 사용.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="layout/basic">
    <th:block layout:fragment="title">
        <title>리스트 페이지</title>
    </th:block>

    <th:block layout:fragment="content">
        <div class="page_tits">
            <h3>게시판 관리</h3>
            <p class="path"><strong>현재 위치 :</strong> <span>게시판 관리</span> <span>리스트형</span> <span>리스트</span></p>
        </div>

        <div class="content">
            <section>
                <!--/* 검색 */-->
                <div class="search_box">
                    <form id="searchForm" onsubmit="return false;" autocomplete="off">
                        <div class="sch_group fl">
                            <select title="검색 유형 선택">
                                <option value="">전체 검색</option>
                                <option value="">제목</option>
                                <option value="">내용</option>
                            </select>
                            <input type="text" placeholder="키워드를 입력해 주세요." title="키워드 입력"/>
                            <button type="button" class="bt_search"><i class="fas fa-search"></i><span class="skip_info">검색</span></button>
                        </div>
                    </form>
                </div>

                <!--/* 리스트 */-->
                <table class="tb tb_col">
                    <colgroup>
                        <col style="width:50px;"/><col style="width:7.5%;"/><col style="width:auto;"/><col style="width:10%;"/><col style="width:15%;"/><col style="width:7.5%;"/>
                    </colgroup>
                    <thead>
                        <tr>
                            <th scope="col"><input type="checkbox"/></th>
                            <th scope="col">번호</th>
                            <th scope="col">제목</th>
                            <th scope="col">작성자</th>
                            <th scope="col">등록일</th>
                            <th scope="col">조회</th>
                        </tr>
                    </thead>

                    <!--/* 리스트 데이터 렌더링 영역 */-->
                    <tbody id="list">

                    </tbody>
                </table>

                <!--/* 페이지네이션 렌더링 영역 */-->
                <div class="paging">

                </div>

                <!--/* 버튼 */-->
                <p class="btn_set tr">
                    <a th:href="@{/post/write.do}" class="btns btn_st3 btn_mid">글쓰기</a>
                </p>
            </section>
        </div> <!--/* .content */-->
    </th:block>
</html>

 

 

15) list.html에 자바스크립트 함수 작성

 

list.html 에서 콘텐츠(content) 프래그먼트 뒤에 랜더링하는 자바 스크립트 추가.

    <th:block layout:fragment="script">
        <script th:inline="javascript">
        /*<![CDATA[*/

            window.onload = () => {
                // 페이지가 로드되었을 때, 딱 한 번만 함수를 실행
                findAllPost();
            }


            // 게시글 리스트 조회
            function findAllPost() {

                // 1. PagingResponse의 멤버인 List<T> 타입의 list를 의미
                const list = [[ ${response.list} ]];

                // 2. 리스트가 비어있는 경우, 행에 "검색 결과가 없다"는 메시지를 출력하고, 페이지 번호(페이지네이션) HTML을 제거(초기화)한 후 로직을 종료
                if ( !list.length ) {
                    document.getElementById('list').innerHTML = '<td colspan="6"><div className="no_data_msg">검색된 결과가 없습니다.</div></td>';
                    drawPage();
                }

                // 3. PagingResponse의 멤버인 pagination을 의미
                const pagination = [[ ${response.pagination} ]];

                // 4. @ModelAttribute를 이용해서 뷰(HTML)로 전달한 SearchDto 타입의 객체인 params를 의미
                const params = [[ ${params} ]];

                // 5. 리스트에 출력되는 게시글 번호를 처리하기 위해 사용되는 변수 (리스트에서 번호는 페이지 정보를 이용해서 계산해야 함)
                let num = pagination.totalRecordCount - ((params.page - 1) * params.recordSize);

                // 6. 리스트 데이터 렌더링
                drawList(list, num);

                // 7. 페이지 번호 렌더링
                drawPage(pagination, params);
            }


            // 리스트 HTML draw
            function drawList(list, num) {

                // 1. 렌더링 할 HTML을 저장할 변수
                let html = '';

                /*
                 * 2. 기존에 타임리프(Thymeleaf)를 이용해서 리스트 데이터를 그리던 것과 유사한 로직
                 *    기존에는 게시글 번호를 (전체 데이터 수 - loop의 인덱스 번호)로 처리했으나, 현재는 (전체 데이터 수 - ((현재 페이지 번호 - 1) * 페이지당 출력할 데이터 개수))로 정밀히 계산
                 */
                list.forEach(row => {
                    html += `
                        <tr>
                            <td><input type="checkbox" /></td>
                            <td>${row.noticeYn === false ? num-- : '공지'}</td>
                            <td class="tl"><a href="/post/view.do?id=${row.id}">${row.title}</a></td>
                            <td>${row.writer}</td>
                            <td>${dayjs(row.createdDate).format('YYYY-MM-DD HH:mm')}</td>
                            <td>${row.viewCnt}</td>
                        </tr>
                    `;
                })

                // 3. id가 "list"인 요소를 찾아 HTML을 렌더링
                document.getElementById('list').innerHTML = html;
            }


            // 페이지 HTML draw
            function drawPage(pagination, params) {

                // 1. 필수 파라미터가 없는 경우, 페이지 번호(페이지네이션) HTML을 제거(초기화)한 후 로직 종료
                if ( !pagination || !params ) {
                    document.querySelector('.paging').innerHTML = '';
                    throw new Error('Missing required parameters...');
                }

                // 2. 렌더링 할 HTML을 저장할 변수
                let html = '';

                // 3. 이전 페이지가 있는 경우, 즉 시작 페이지(startPage)가 1이 아닌 경우 첫 페이지 버튼과 이전 페이지 버튼을 HTML에 추가
                if (pagination.existPrevPage) {
                    html += `
                        <a href="javascript:void(0);" onclick="movePage(1)" class="page_bt first">첫 페이지</a>
                        <a href="javascript:void(0);" onclick="movePage(${pagination.startPage - 1})" class="page_bt prev">이전 페이지</a>
                    `;
                }

                /*
                 * 4. 시작 페이지(startPage)와 끝 페이지(endPage) 사이의 페이지 번호(i)를 넘버링 하는 로직
                 *    페이지 번호(i)와 현재 페이지 번호(params.page)가 동일한 경우, 페이지 번호(i)를 활성화(on) 처리
                 */
                html += '<p>';
                for (let i = pagination.startPage; i <= pagination.endPage; i++) {
                    html += (i !== params.page)
                        ? `<a href="javascript:void(0);" onclick="movePage(${i});">${i}</a>`
                        : `<span class="on">${i}</span>`
                }
                html += '</p>';

                // 5. 현재 위치한 페이지 뒤에 데이터가 더 있는 경우, 다음 페이지 버튼과 끝 페이지 버튼을 HTML에 추가
                if (pagination.existNextPage) {
                    html += `
                        <a href="javascript:void(0);" onclick="movePage(${pagination.endPage + 1});" class="page_bt next">다음 페이지</a>
                        <a href="javascript:void(0);" onclick="movePage(${pagination.totalPageCount});" class="page_bt last">마지막 페이지</a>
                    `;
                }

                // 6. class가 "paging"인 요소를 찾아 HTML을 렌더링
                document.querySelector('.paging').innerHTML = html;
            }


            // 페이지 이동
            function movePage(page) {

                // 1. drawPage( )의 각 버튼에 선언된 onclick 이벤트를 통해 전달받는 page(페이지 번호)를 기준으로 객체 생성
                const queryParams = {
                    page: (page) ? page : 1,
                    recordSize: 10,
                    pageSize: 10
                }

                /*
                 * 2. location.pathname : 리스트 페이지의 URI("/post/list.do")를 의미
                 *    new URLSearchParams(queryParams).toString() : queryParams의 모든 프로퍼티(key-value)를 쿼리 스트링으로 변환
                 *    URI + 쿼리 스트링에 해당하는 주소로 이동
                 *    (해당 함수가 리턴해주는 값을 브라우저 콘솔(console)에 찍어보시면 쉽게 이해하실 수 있습니다.)
                 */
                location.href = location.pathname + '?' + new URLSearchParams(queryParams).toString();
            }

        /*]]>*/
        </script>
    </th:block>

 

 

 

16) 테스트 해보기

5페이지 클릭했을 때 결과

 

쿼리스트링도 정상적으로 출력됨

 

 

5페이지에서 ' > '  버튼 2번 클릭했을 때 - 한 번 클릭 시 10단위로 넘어감

 

5페이지에서 ' >> ' 버튼 클릭했을 때 - 맨 끝 페이지로 이동

 

끝 페이지에서 ' < ' 버튼 두 번 클릭했을 때

 

오른쪽 끝 페이지에서 ' << ' 버튼 클릭 했을 때 - 맨 처음 페이지로 이동

 

 

페이징 기능 개선하기

 

위에서는 페이징과 검색 기능에 공통으로 필요한 클래스를 작성하고, 게시글 목록 화면단에 페이징 기능을 적용 시킴.

 

이제 검색 기능 추가 + 특정 페이지에서 어떤 작업이 발생했을 때, 이전 페이지 정보와 검색 조건이 유지되도록 할 것임.

 

검색 기능 구현

 

페이징과 검색 기능은 하나의 세트라고 보면 됨.

 

페이징은 전체 데이터를 카운팅 한 기준으로 1 ~ N개까지의 페이지를 보여주지만, 검색 기능은 전체 데이터에서 LIKE 검색 결과, 즉 필터링된 데이터를 카운팅하여 1 ~ N개까지의 페이지를 보여준다.

 

검색 기능은 이전 글에서 생성한 SearchDto 클래스의 검색 키워드(keyword)와 검색 유형(searchType)을 활용하고, MyBatis의 Dynamic SQL 기능을 이용해서 처리할 것이다.

 

 

1) 검색 영역 HTML 수정하기 

 

서버 로직 구성 전에 화면단 부터 처리 해보려고 한다.

 

list.html

 

기존 코드에서 밑줄 친 코드 추가 + 옵션 value 지정 해주기

 

searchForm : 검색 폼. 폼의 검색 유형(searchType)과 키워드(keyword)를 SQL 쿼리를 이용해 게시글을 검색한다.

movePage() : 검색 버튼에 연결된 클릭 이벤트. movePage()란 페이지를 이동하는 기능으로, 검색 버튼을 클릭했을 때 movePage()의 인자로 '1'을 전달하겠다는 것을 말함. 검색 처리에서 현재 페이지 번호(page)는 항상 1로 유지된 상태. 

 

※1의 상태를 유지하는 이유

: 사용자(A)가 5페이지에서 다른 사용자(B)가 작성한 글을 보려 해당 작성자(B)로 검색했다고 가정하자.

  만약 작성자(B)가 올린 글이 40개가 넘지 않는다면 1, 2, 3, 4 페이지에서는 글이 존재하나, 사용자가 기존에 머물러 있던 5페이지

  기준으로 검색이 되어 작성자(B)의 게시글은 보이지 않게 된다. 이런 상황을 방지하기 위함!

 

 

2) movePage( ) 함수 수정하기

 

list.html

 

    // 페이지 이동
    function movePage(page) {

        // 1. 검색 폼
        const form = document.getElementById('searchForm');

        // 2. drawPage( )의 각 버튼에 선언된 onclick 이벤트를 통해 전달받는 page(페이지 번호)를 기준으로 객체 생성
        const queryParams = {
            page: (page) ? page : 1,
            recordSize: 10,
            pageSize: 10,
            searchType: form.searchType.value,
            keyword: form.keyword.value
        }

        /*
         * 3. location.pathname : 리스트 페이지의 URI("/post/list.do")를 의미
         *    new URLSearchParams(queryParams).toString() : queryParams의 모든 프로퍼티(key-value)를 쿼리 스트링으로 변환
         *    URI + 쿼리 스트링에 해당하는 주소로 이동
         *    (해당 함수가 리턴해주는 값을 브라우저 콘솔(console)에 찍어보시면 쉽게 이해하실 수 있습니다.)
         */
        location.href = location.pathname + '?' + new URLSearchParams(queryParams).toString();
    }

 

 

SQL 쿼리의 검색 조건으로 사용하기 위해, 검색 영역의 searchType과 keyword를 파라미터로 전달해준다.

 

게시글 목록 페이지에서 검색 유형과 키워드를 세팅하고 검색해 보면, searchType과 keyword가 파라미터로 함께 전송된된다. 위 사진은 검색 유형을 제목으로 하고 키워드를 1000번으로 입력하여 검색한 결과이다.

 

 

3) MyBatis 동적(Dynamic) SQL 알아보기

 

마이바티스는 동적 SQL 처리 가능한 태그와 표현식을 제공한다. 주로 조건문과 반복문이 많이 사용되는데, 이 중에서도 조건문의 if, choose와 반복문의 foreach가 가장 많이 사용됨.

 

반복문의 foreach는 SQL 쿼리에서 WHERE 조건의 IN() 구문에 주로 사용되는데, 파라미터의 타입은 List, Array, Map 등의 컬렉션(Collection) 타입이어야 한다! 

 

// if
// Java의 if 문과 동일
<if test="조건">
 ...
</if>

// choose when otherwise
// Java의 switch 문과 동일. when은 각각의 조건, otherwise는 그 외의 조건을 처리
<choose>
	<when test="조건1">...</when>
	<when test="조건2">...</when>    
	<when test="조건3">...</when>
    <otherwise>...</otherwise>
</choose>

// trim where set
// 로직을 처리하면서 필요한 구문을 변경
<trim prefix="WHERE" prifixOverrides="AND|OR">
...
</trim>

// foreach
// 컬렉션에 대해 반복문 실행. List, Array, Map 등에 담긴 여러 개의 데이터를 처리
<foreach collection="list" item="item" index="index" open="(" separator="," close=")">
	#{item}
</foreach>

 

4) XML Mapper 수정하기

 

postMapper.xml

우선 검색용 SQL 조각 추가해주기.

    <!-- 게시글 검색 -->
    <sql id="search">
        <!-- 검색 키워드가 있을 때 -->
        <if test="keyword != null and keyword != ''">
            <choose>
                <!-- 검색 유형이 있을 때 -->
                <when test="searchType != null and searchType != ''">
                    <choose>
                        <when test="'title'.equals( searchType )">
                            AND title LIKE CONCAT('%', #{keyword}, '%')
                        </when>
                        <when test="'content'.equals( searchType )">
                            AND content LIKE CONCAT('%', #{keyword}, '%')
                        </when>
                        <when test="'writer'.equals( searchType )">
                            AND writer LIKE CONCAT('%', #{keyword}, '%')
                        </when>
                    </choose>
                </when>
                
                <!-- 전체 검색일 때 -->
                <otherwise>
                    AND (
                           title LIKE CONCAT('%', #{keyword}, '%')
                        OR content LIKE CONCAT('%', #{keyword}, '%')
                        OR writer LIKE CONCAT('%', #{keyword}, '%')
                    )
                </otherwise>
            </choose>
        </if>
    </sql>

 

검색 키워드(keyword)가 파라미터로 넘어온 경우에만 실행되는 쿼리이다.

 

검색 유형(searchType)이 선택되면 <when> 조건에 해당하는 LIKE 쿼리가 실행.

전체 검색인 경우에는 <otherwise> 안에 선언한 LIKE 쿼리가 실행.

 

 

그 다음 검색용 SQL 조각을 인클루드(Include) 할 것이다.

findAll 쿼리와 count 쿼리에서 search SQL 쿼리를 WHERE 절 안에 인클루드 해주면 된다.

 

    <!-- 게시글 리스트 조회 -->
    <select id="findAll" parameterType="com.study.common.dto.SearchDto" resultType="com.study.domain.post.PostResponse">
        SELECT
            <include refid="postColumns" />
        FROM
            tb_post
        WHERE
            delete_yn = 0
            <include refid="search" />
        ORDER BY
            id DESC
        LIMIT #{pagination.limitStart}, #{recordSize}
    </select>
    
    
    <!-- 게시글 수 카운팅 -->
    <select id="count" parameterType="com.study.common.dto.SearchDto" resultType="int">
        SELECT
            COUNT(*)
        FROM
            tb_post
        WHERE
            delete_yn = 0
            <include refid="search" />
    </select>

 

 

5) 검색 기능 테스트

 

전체검색 결과

전체검색 - 1000번 결과

 

count 쿼리 실행 결과
findAll 쿼리 실행 결과

 

 

제목으로 검색 결과

전체검색 - 500번 결과

 

count 쿼리 실행 결과
findAll 쿼리 실행 결과

 

 


 

모든 코드는 아래 블로그를 참고합니다!

 

스프링 부트(Spring Boot) - 페이징(Paging) & 검색(Search) 처리하기 1/2 [Thymeleaf, MariaDB, IntelliJ, Gradle, MyBat

본 게시판 프로젝트는 단계별(step by step)로 진행되니, 이전 단계를 진행하시는 것을 권장드립니다. DBMS 툴은 DBeaver를 이용하며, DB는 MariaDB를 이용합니다. (MariaDB 설치하기) 화면 처리는 HTML5 기반

congsong.tistory.com

 

 

 

스프링 부트(Spring Boot) - 페이징(Paging) & 검색(Search) 처리하기 2/2 [Thymeleaf, MariaDB, IntelliJ, Gradle, MyBat

본 게시판 프로젝트는 단계별(step by step)로 진행되니, 이전 단계를 진행하시는 것을 권장드립니다. DBMS 툴은 DBeaver를 이용하며, DB는 MariaDB를 이용합니다. (MariaDB 설치하기) 화면 처리는 HTML5 기반

congsong.tistory.com

 

 

키워드와 검색 조건 유지하기

 

검색 버튼을 클릭하면 검색 조건이 풀려버리는 점을 해결하기 위한 쿼리 스트링 파라미터 세팅 기능을 구현하자.

 

쿼리 스트링 유지용 함수 추가

 

list.html

    // 페이지가 로드되었을 때, 딱 한 번만 함수를 실행
    window.onload = () => {
        setQueryStringParams();

        findAllPost();
    }


    // 쿼리 스트링 파라미터 셋팅
    function setQueryStringParams() {

        if ( !location.search ) {
            return false;
        }

        const form = document.getElementById('searchForm');

        new URLSearchParams(location.search).forEach((value, key) => {
            if (form[key]) {
                form[key].value = value;
            }
        })
    }

 

자바스크립트에서 location 객체의 search를 이용하면 쿼리 스트링 파라미터를 조회할 수 있다.

 

form은 게시글 목록 페이지의 검색 폼(searchForm)을 의미.

new URLSearchParams() 함수의 인자로 현재  페이지의 쿼리스트링을 전달 -> 쿼리 스트링 문자열에 포함된 각 파라미터(key=value)를 객체화 -> 검색 유형(searchType)과 키워드(keyword)의 값을 searchForm에 세팅한다. 

 

 

검색 기능 테스트

 

이전에는 검색을 하고나면 조건이 풀려 새로고침 or 페이지 이동 시 올바른 검색이 되지 않았음.

 

함수 추가 후 테스트 결과. 페이지를 옮겨도 검색어 유지된 채로 보여줌.

 

 

이전 페이지 정보 유지하기

 

게시글을 수정/삭제 하거나, 상세 또는 수정 페이지에서 뒤로 버튼을 클릭했을 때, 이전 페이지 정보가 유지되도록 하자.

 

 

drawList() 함수 수정

 

글 목록 페이지에서 글 상세 페이지로 이동할 때 쿼리 스트링 파라미터를 전달하도록 drowList() 함수 변경

 

list.html 

    // 리스트 HTML draw
    function drawList(list, num) {
    
        // 1. 렌더링 할 HTML을 저장할 변수
        let html = '';
    
        /*
         * 2. 기존에 타임리프(Thymeleaf)를 이용해서 리스트 데이터를 그리던 것과 유사한 로직
         *    기존에는 게시글 번호를 (전체 데이터 수 - loop의 인덱스 번호)로 처리했으나, 현재는 (전체 데이터 수 - ((현재 페이지 번호 - 1) * 페이지당 출력할 데이터 개수))로 정밀히 계산
         */
        list.forEach(row => {
            html += `
                <tr>
                    <td><input type="checkbox" /></td>
                    <td>${row.noticeYn === false ? num-- : '공지'}</td>
                    <td class="tl"><a href="javascript:void(0);" onclick="goViewPage(${row.id});">${row.title}</a></td>
                    <td>${row.writer}</td>
                    <td>${dayjs(row.createdDate).format('YYYY-MM-DD HH:mm')}</td>
                    <td>${row.viewCnt}</td>
                </tr>
            `;
        })
    
        // 3. id가 "list"인 요소를 찾아 HTML을 렌더링
        document.getElementById('list').innerHTML = html;
    }

 

기존에는 제목 클릭 시 게시글 상세 페이지로 이동하는 구조였다면, 이제는 href 속성을 무효시키고 onclick 이벤트로 goViewPage() 함수 호출.

 

 

goViewPage() 함수 선언

 

list.html

    // 게시글 상세 페이지로 이동
    function goViewPage(id) {
        const queryString = (location.search) ? location.search + `&id=${id}` : `?id=${id}`;
        location.href = '/post/view.do' + queryString;
    }

 

(로직 설명 생략)

 

 

글 상세 페이지 버튼 영역 수정

 

view.html

    <p class="btn_set">
        <button type="button" onclick="goWritePage();" class="btns btn_bdr4 btn_mid">수정</button>
        <button type="button" onclick="deletePost();" class="btns btn_bdr1 btn_mid">삭제</button>
        <button type="button" onclick="goListPage();" class="btns btn_bdr3 btn_mid">뒤로</button>
    </p>

 

 

글 상세 페이지에 goWritePage() 함수 선언

    // 게시글 수정 페이지로 이동
    function goWritePage() {
        location.href = '/post/write.do' + location.search;
    }

 

(로직 설명 생략)

 

글 상세 페이지에 goListPage() 함수 선언

    // 게시글 리스트 페이지로 이동
    function goListPage() {
        const queryString = new URLSearchParams(location.search);
        queryString.delete('id');
        location.href = '/post/list.do' + '?' + queryString.toString();
    }

 

(로직 설명 생략)

 

글 상세 페이지에 deletePost() 함수 수정

    // 게시글 삭제
    function deletePost() {
        
        const id = [[ ${post.id} ]];
        
        if ( !confirm(id + '번 게시글을 삭제할까요?') ) {
            return false;
        }

        let inputHtml = '';
        
        new URLSearchParams(location.search).forEach((value, key) => {
            inputHtml += `<input type="hidden" name="${key}" value="${value}" />`;
        })

        const formHtml = `
            <form id="deleteForm" action="/post/delete.do" method="post">
                ${inputHtml}
            </form>
        `;
        
        const doc = new DOMParser().parseFromString(formHtml, 'text/html');
        const form = doc.body.firstChild;
        document.body.append(form);
        document.getElementById('deleteForm').submit();
    }

 

(로직 설명 생략)

 

컨트롤러에 deletePost() 메서드 수정

    // 게시글 삭제
    @PostMapping("/post/delete.do")
    public String deletePost(@RequestParam final Long id, final SearchDto queryParams, Model model) {
        postService.deletePost(id);
        MessageDto message = new MessageDto("게시글 삭제가 완료되었습니다.", "/post/list.do", RequestMethod.GET, queryParamsToMap(queryParams));
        return showMessageAndRedirect(message, model);
    }

 

(로직 설명 생략)

 

컨트롤러에 queryParamsToMap() 메서드 추가

    // 쿼리 스트링 파라미터를 Map에 담아 반환
    private Map<String, Object> queryParamsToMap(final SearchDto queryParams) {
        Map<String, Object> data = new HashMap<>();
        data.put("page", queryParams.getPage());
        data.put("recordSize", queryParams.getRecordSize());
        data.put("pageSize", queryParams.getPageSize());
        data.put("keyword", queryParams.getKeyword());
        data.put("searchType", queryParams.getSearchType());
        return data;
    }

 

(로직 설명 생략)

 

이전 페이지 정보 유지 테스트

 

 

 


모든 코드 아래 블로그를 참고합니다!

 

스프링 부트(Spring Boot) - 페이징(Paging) & 검색(Search) 처리하기 2/2 [Thymeleaf, MariaDB, IntelliJ, Gradle, MyBat

본 게시판 프로젝트는 단계별(step by step)로 진행되니, 이전 단계를 진행하시는 것을 권장드립니다. DBMS 툴은 DBeaver를 이용하며, DB는 MariaDB를 이용합니다. (MariaDB 설치하기) 화면 처리는 HTML5 기반

congsong.tistory.com