본문 바로가기
Spring Boot

스프링 부트(Spring Boot) 게시판 - REST API 방식으로 댓글 페이징(Paging) 처리하기 [Thymeleaf, MariaDB, IntelliJ, Gradle, MyBatis]

by 도뎡 2023. 4. 25.
반응형
  • 본 게시판 프로젝트는 단계별(step by step)로 진행되니, 이전 단계를 진행하시는 것을 권장드립니다.
  • DBMS 툴은 DBeaver를 이용하며, DB는 MariaDB를 이용합니다. (MariaDB 설치하기)
  • 화면 처리는 HTML5 기반의 자바 템플릿 엔진인 타임리프(Thymeleaf)를 사용합니다.

이전 글에서는 댓글 삭제 기능을 구현했고, 이를 끝으로 댓글 CRUD 처리가 모두 완료되었습니다. 이번에는 게시글 페이징검색 처리에서 구현한 클래스들을 이용해서 댓글에 페이징을 적용해 볼 건데요. 지금까지와 마찬가지로 jQuery의 Ajax를 이용해서 화면의 움직임 없이 페이지를 이동하도록 처리해 보도록 하겠습니다.

 

1. 댓글 조회용 DTO 클래스 생성하기

우선 java 디렉터리에 다음의 클래스를 생성한 후 소스 코드를 작성해 주세요.

package com.study.domain.comment;

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

@Getter
@Setter
public class CommentSearchDto extends SearchDto {

    private Long postId;    // 게시글 번호 (FK)

}

 

아래 클래스는 게시글 페이징 처리에서 생성했던 SearchDto 클래스의 구조입니다. 게시글과 마찬가지로 댓글도 SearchDto의 멤버 변수들을 이용해서 페이징을 적용하는데요. 댓글은 tb_comment 테이블의 post_id를 기준으로 SELECT 하기 때문에 게시글 번호를 필수 파라미터로 가져가야 합니다.

SearchDto 클래스 구조

 

2. CommentMapper 인터페이스 수정하기

다음으로 CommentMapper의 findAll( )과 count( )가 CommentSearchDto 객체를 파라미터로 전달받을 수 있도록 변경해 주세요.

    /**
     * 댓글 리스트 조회
     * @param params - search conditions
     * @return 댓글 리스트
     */
    List<CommentResponse> findAll(CommentSearchDto params);

    /**
     * 댓글 수 카운팅
     * @param params - search conditions
     * @return 댓글 수
     */
    int count(CommentSearchDto params);

 

3. CommentMapper XML 수정하기

이번에는 XML Mapper입니다. findAll 쿼리를 다음과 같이 변경하고, count 쿼리를 새로 작성해 주세요.

    <!-- 댓글 리스트 조회 -->
    <select id="findAll" parameterType="com.study.domain.comment.CommentSearchDto" resultType="com.study.domain.comment.CommentResponse">
        SELECT
            <include refid="commentColumns" />
        FROM
            tb_comment
        WHERE
            delete_yn = 0
            AND post_id = #{postId}
        ORDER BY
            id DESC
        LIMIT #{pagination.limitStart}, #{recordSize}
    </select>
    
    
    <!-- 댓글 수 카운팅 -->
    <select id="count" parameterType="com.study.domain.comment.CommentSearchDto" resultType="int">
        SELECT
            COUNT(*)
        FROM
            tb_comment
        WHERE
            delete_yn = 0
            AND post_id = #{postId}
    </select>

 

4. CommentService 클래스 수정하기

리스트 데이터와 계산된 페이지 정보를 함께 리턴해주기 위해 findAllComment( )를 다음과 같이 변경해 주세요.

    /**
     * 댓글 리스트 조회
     * @param params - search conditions
     * @return list & pagination information
     */
    public PagingResponse<CommentResponse> findAllComment(final CommentSearchDto params) {

        int count = commentMapper.count(params);
        if (count < 1) {
            return new PagingResponse<>(Collections.emptyList(), null);
        }

        Pagination pagination = new Pagination(count, params);
        List<CommentResponse> list = commentMapper.findAll(params);
        return new PagingResponse<>(list, pagination);
    }

 

로직 해석

PostService의 findAllPost( )와 거의 동일한 로직입니다. findAllComment( )는 params에 계산된 페이지 정보(pagination)를 저장하는 로직이 없는데요. 이 로직은 페이징이 필요한 모든 기능에서 공통으로 사용되는 로직이기 때문에 Pagination 생성자에서 처리해 주는 게 나을 듯합니다.

PostService - findAllPost( ) 구조

 

5. Pagination 클래스 수정하기

Pagination 생성자 메서드를 다음과 같이 변경해 주세요.

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

 

로직 해석

마지막 로직을 통해 params에 계산된 페이지 정보(this)를 저장합니다. 기존에는 서비스에서 처리되던 로직이 Pagination 객체가 생성되는 시점에 실행되도록 변경한 건데요. Pagination으로 책임을 전가함으로써 페이징 적용에 필요한 비즈니스 로직 한 줄이 줄어들었습니다. (코드 리팩터링)

 

6. CommentApiController 수정하기

이번에는 findAllComment( )를 다음과 같이 변경해 주시면 되는데요. 리턴 타입의 변경과, 파라미터 추가 선언이 전부이니 설명은 생략하도록 하겠습니다.

    // 댓글 리스트 조회
    @GetMapping("/posts/{postId}/comments")
    public PagingResponse<CommentResponse> findAllComment(@PathVariable final Long postId, final CommentSearchDto params) {
        return commentService.findAllComment(params);
    }

 

7. 공통 Ajax 함수 선언하기


7-1) 기존 Ajax 함수의 문제

아래 코드는 댓글을 저장 처리하는 saveComment( )의 일부 로직입니다. 실질적으로 서버와의 통신이 종료된 후 실행되는 메인 로직은 success( ) 함수 내에 선언된 네 줄의 코드인데, 전체 코드는 10줄이 훌쩍 넘어가는 상황입니다.

saveComment( ) - Ajax 함수 구조

 

7-2) 공통 함수 선언하기

이를 해결하기 위해, 조회(GET) 전용 함수와 저장(POST), 수정(PATCH), 삭제(DELETE) 전용 함수를 선언해서 공통으로 사용해 보겠습니다. 우선 function.js에 다음의 함수들을 추가해 주세요.

/**
 * 데이터 조회
 * @param uri - API Request URI
 * @param params - Parameters
 * @returns json - 결과 데이터
 */
function getJson(uri, params) {

    let json = {}

    $.ajax({
        url : uri,
        type : 'get',
        dataType : 'json',
        data : params,
        async : false,
        success : function (response) {
            json = response;
        },
        error : function (request, status, error) {
            console.log(error)
        }
    })

    return json;
}


/**
 * 데이터 저장/수정/삭제
 * @param uri - API Request URI
 * @param method - API Request Method
 * @param params - Parameters
 * @returns json - 결과 데이터
 */
function callApi(uri, method, params) {

    let json = {}

     $.ajax({
        url : uri,
        type : method,
        contentType : 'application/json; charset=utf-8',
        dataType : 'json',
        data : (params) ? JSON.stringify(params) : {},
        async : false,
        success : function (response) {
            json = response;
        },
        error : function (request, status, error) {
            console.log(error)
        }
    })

    return json;
}

 

함수 설명
getJson( ) 데이터 조회(SELECT) 전용으로 사용될 함수입니다. 우리는 이전에 모든 요청과 응답 데이터를 JSON 포맷으로 주고받기로 약속했습니다. 즉, 서버에서 내려오는 모든 응답 데이터는 JSON이기 때문에 전달받을 데이터의 타입(dataType)은 "json"으로 고정이며, 요청 URI(uri)와 파라미터(params)만 유동적으로 처리해주면 됩니다.
callApi( ) CRUD에서 조회(Read)를 제외한 나머지 API를 호출하는 용도로 사용될 함수입니다. getJson( )과 차이가 있다면 요청 메서드(method)를 파라미터로 전달받는 것, 객체를 JSON 문자열로 전송하기 위해 contentType 옵션과 JSON.stringify( )가 사용된다는 것입니다.

 

8. 상세 페이지(view.html) JS 함수 수정하기

이제 function.js에 선언한 함수들을 이용해서 코드 라인을 줄여보겠습니다.


8-1) saveComment( ) 수정하기

댓글을 저장하는 함수입니다. Ajax 함수만 callApi( )로 대체했을 뿐인데 코드가 현저히 줄어들었습니다. 각 변수에 담긴 값들을 함수의 파라미터로 직접 전달하면 코드를 더 줄일 수 있을 듯합니다.

findAllComment( )의 인자로 전달하는 '1'은 뒤에서 설명드리겠습니다.

    // 댓글 저장
    function saveComment() {

        const content = document.getElementById('content');
        isValid(content, '댓글');

        const postId = [[ ${post.id} ]];
        const uri = `/posts/${postId}/comments`;
        const params = {
            postId : postId,
            content : content.value,
            writer : '홍길동'
        }

        callApi(uri, 'post', params);
        alert('저장되었습니다.');
        content.value = '';
        document.getElementById('counter').innerText = '0/300자';
        findAllComment(1);
    }

 

8-2) updateComment( ) 수정하기

댓글을 수정하는 함수입니다. 마찬가지로 코드가 눈에 띄게 줄어들었으며, 해당 함수는 findAllComment( )로 번호를 전달하지 않는다는 특징이 있습니다.

    // 댓글 수정
    function updateComment(id) {

        const writer = document.getElementById('modalWriter');
        const content = document.getElementById('modalContent');
        isValid(writer, '작성자');
        isValid(content, '수정할 내용');

        const postId = [[ ${post.id} ]];
        const uri = `/posts/${postId}/comments/${id}`;
        const params = {
            id : id,
            postId : postId,
            content : content.value,
            writer : writer.value
        }

        callApi(uri, 'patch', params);
        alert('수정되었습니다.');
        closeCommentUpdatePopup();
        findAllComment();
    }

 

8-3) deleteComment( ) 수정하기

댓글을 삭제하는 함수입니다. URI만으로 삭제 처리할 수 있기 때문에 callApi( )로 URI와 요청 메서드만 전달하며, updateComment( )와 마찬가지로 findAllComment( )로 번호를 전달하지 않습니다.

    // 댓글 삭제
    function deleteComment(id) {

        if ( !confirm('선택하신 댓글을 삭제할까요?') ) {
            return false;
        }

        const postId = [[ ${post.id} ]];
        const uri = `/posts/${postId}/comments/${id}`;
        callApi(uri, 'delete');
        alert('삭제되었습니다.');
        findAllComment();
    }

 

8-4) openCommentUpdatePopup( ) 수정하기

댓글 수정 팝업을 오픈하는 함수입니다. 이 함수엔 getJson( )이 사용되었는데요. URI만으로 기존 댓글 정보를 조회할 수 있기 때문에 파라미터(params)는 전달하지 않습니다.

    // 댓글 수정 팝업 open
    function openCommentUpdatePopup(id) {

        const postId = [[ ${post.id} ]];
        const uri = `/posts/${postId}/comments/${id}`;
        const response = getJson(uri);
        document.getElementById('modalWriter').value = response.writer;
        document.getElementById('modalContent').value = response.content;
        document.getElementById('commentUpdateBtn').setAttribute('onclick', `updateComment(${id})`);
        layerPop('commentUpdatePopup');
    }

 

9. 상세 페이지(view.html) 페이징 처리하기

마지막으로 view.html에 페이징 영역과 두 개의 함수를 선언한 후 findAllComment( )에서 이 함수들을 호출해 주기만 하면 페이징 처리는 완료되며, 변경된 CommentApiController의 findAllComment( )는 다음과 같은 형태의 응답 데이터를 내려줍니다.

CommentApiController - findAllComment( ) 응답 데이터


9-1) 페이지네이션 렌더링 영역 추가

view.html의 댓글 렌더링 영역(<div class="cm_list"></div>) 아래 다음의 코드를 추가해 주세요.

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

    </div>

 

9-2) drawComments( ) 함수 선언하기

댓글 HTML을 화면에 렌더링 합니다. 파라미터명을 제외하고는 기존 findAllComment( )의 메인 로직과 완전히 동일합니다.

    // 댓글 HTML draw
    function drawComments(list) {

        if ( !list.length ) {
            document.querySelector('.cm_list').innerHTML = '<div class="cm_none"><p>등록된 댓글이 없습니다.</p></div>';
            return false;
        }

        let commentHtml = '';

        list.forEach(row => {
            commentHtml += `
                <div>
                    <span class="writer_img"><img src="/images/default_profile.png" width="30" height="30" alt="기본 프로필 이미지"/></span>
                    <p class="writer">
                        <em>${row.writer}</em>
                        <span class="date">${dayjs(row.createdDate).format('YYYY-MM-DD HH:mm')}</span>
                    </p>
                    <div class="cont"><div class="txt_con">${row.content}</div></div>
                    <p class="func_btns">
                        <button type="button" onclick="openCommentUpdatePopup(${row.id});" class="btns"><span class="icons icon_modify">수정</span></button>
                        <button type="button" onclick="deleteComment(${row.id});" class="btns"><span class="icons icon_del">삭제</span></button>
                    </p>
                </div>
            `;
        })

        document.querySelector('.cm_list').innerHTML = commentHtml;
    }

 

9-3) drawPage( ) 함수 선언하기

페이징 HTML을 화면에 렌더링 합니다. 리스트 페이지(list.html)의 drawPage( )와는 약간의 차이가 있습니다.

    // 페이지네이션 HTML draw
    function drawPage(pagination, page) {

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

        // 2. 페이지네이션 HTML을 그릴 변수
        let html = '';

        // 3. 첫/이전 페이지 버튼 추가
        if (pagination.existPrevPage) {
            html += `
                <a href="javascript:void(0);" onclick="findAllComment(1)" class="page_bt first">첫 페이지</a>
                <a href="javascript:void(0);" onclick="findAllComment(${pagination.startPage - 1})" class="page_bt prev">이전 페이지</a>
            `;
        }

        // 4. 페이지 번호 추가
        html += '<p>';
        for (let i = pagination.startPage; i <= pagination.endPage; i++) {
            html += `<a href="javascript:void(0);" onclick="findAllComment(${i});">${i}</a>`
        }
        html += '</p>';

        // 5. 다음/끝 페이지 버튼 추가
        if (pagination.existNextPage) {
            html += `
                <a href="javascript:void(0);" onclick="findAllComment(${pagination.endPage + 1});" class="page_bt next">다음 페이지</a>
                <a href="javascript:void(0);" onclick="findAllComment(${pagination.totalPageCount});" class="page_bt last">마지막 페이지</a>
            `;
        }

        // 6. <div class="paging"></div> 태그에 변수 html에 담긴 내용을 렌더링
        const paging = document.querySelector('.paging');
        paging.innerHTML = html;

        // 7. 사용자가 클릭한 페이지 번호(page) 또는 끝 페이지 번호(totalPageCount)에 해당되는 a 태그를 찾아 활성화(active) 처리한 후 클릭 이벤트 제거
        const currentPage = Array.from(paging.querySelectorAll('a')).find(a => (Number(a.text) === page || Number(a.text) === pagination.totalPageCount));
        currentPage.classList.add('on');
        currentPage.removeAttribute('onclick');
    }

 

로직 해석

계산된 페이지 정보(pagination)와 현재 페이지 번호(page)를 전달받아 페이지네이션 HTML을 화면에 렌더링 합니다. 게시글 페이징 처리와는 약간의 차이가 있는데요. 두 번째 파라미터가 "params"에서 "page"로 변경된 것과, 페이지 번호의 활성화(on) 처리를 4번 로직이 아닌 7번 로직에서 한다는 것입니다.

추가적으로 7번 로직에서 currentPage를 찾는 두 번째 조건(Number(a.text) === pagination.totalPageCount)은 댓글을 삭제하는 경우에만 해당되는데요. 자세한 내용은 테스트 과정에서 설명드리겠습니다.

(여기서 기존 코드와 새로운 코드를 쉽게 비교해 보실 수 있습니다.)

 

9-4) findAllComment( ) 함수 수정하기

마지막으로 findAllComment( )에서 앞에 선언한 두 개의 함수를 호출해 댓글과 페이지네이션 HTML을 화면에 렌더링 해주면 됩니다.

    // 전체 댓글 조회
    function findAllComment(page) {

        const currentPage = document.querySelector('.paging a.on');
        page = (page) ? page : (currentPage ? Number(currentPage.text) : 1);

        const postId = [[ ${post.id}]];
        const uri = `/posts/${postId}/comments`;
        const params = {
            page : page,
            recordSize : 5,
            pageSize : 10,
            postId : postId,
        }

        const response = getJson(uri, params);
        const pagination = response.pagination;
        drawComments(response.list);
        drawPage(pagination, page);
    }

 

구성 요소 설명
page 사용자가 클릭한 페이지 번호를 의미합니다. 
currentPage 페이지네이션 영역에서 활성화(on)된 페이지 번호를 의미합니다. 두 번째 로직에서 page의 유무를 기준으로 삼항 연산자가 실행되는데요. 페이지가 처음 로딩되는 시점에는 drawPage( )가 실행되지 않은 상태이기 때문에 DOM 내에 페이지네이션 HTML이 없을 테고, 이는 currentPage에 담기는 값이 "null"임을 의미합니다.

쉽게 말해 파라미터로 page가 넘어오면 전달받은 값 그대로 저장되고, 반대의 경우에는 현재 활성화(on) 된 페이지 번호를 저장하며, 둘 다 없는 경우에는 1을 저장합니다. 이는 테스트 과정에서 다시 한번 설명드리겠습니다.
response 댓글(list)과 계산된 페이지 정보(pagination)를 담은 객체입니다.
drawComments( ) response의 list 객체배열을 이용해서 댓글 HTML을 DOM에 렌더링합니다.
drawPage( ) response의 pagination 객체를 이용해서 페이지네이션 HTML을 DOM에 렌더링합니다.

 

10. 댓글 페이징 테스트 해보기

저는 DB에서 댓글(tb_comment) 테이블을 초기화한 후 100개의 댓글을 새로 등록했습니다.


10-1) 페이지 이동

1페이지에서 7페이지로 이동했습니다.

페이지 이동 (1 to 7)

 

7페이지에서 다음 페이지 버튼( > )을 클릭해 11페이지로 이동했습니다.

페이지 이동 (7 to 11)

 

11페이지에서 이전 페이지 버튼( < )을 클릭해 10페이지로 이동했습니다.

페이지 이동 (11 to 10)

 

다시 11페이지로 이동한 후 첫 페이지 버튼( << )을 클릭해 1페이지로 이동했습니다.

페이지 이동 (11 to 1)

 

마지막으로 1페이지에서 마지막 페이지 버튼( >> )을 클릭해 마지막 페이지로 이동했습니다.

페이지 이동 (1 to 20)

 

10-2) 신규 댓글 등록

이번에는 20페이지에서 새로운 댓글을 등록했습니다.

신규 댓글 등록 화면 (20페이지)

 

저장이 완료된 후 findAllComment( )의 page로 '1'을 전달해서 첫 페이지로 이동합니다.

저장 후 페이지 이동 (20 to 1)

 

10-3) 댓글 수정

다음은 15페이지에서 30번 댓글을 수정해 본 결과입니다.

댓글 수정 화면 (15페이지 - 30번)

 

수정이 완료된 후 페이지 번호가 정상적으로 유지되고 있습니다.

페이지 번호 유지 (15페이지)

 

이는 findAllComment( )로 파라미터를 전달하지 않았을 때, 페이지네이션에서 현재 활성화(on) 된 currentPage의 번호(15)가 page에 담기기 때문입니다.

currentPage를 이용한 페이지 번호 유지 로직

 

댓글 수정 시 findAllComment( )의 page에 담기는 값

 

10-4) 댓글 삭제

이번에는 마지막 페이지의 1번 댓글을 삭제해 본 결과입니다.

댓글 삭제 화면 (21페이지 - 1번)

 

삭제된 데이터를 제외하고, 100개의 데이터를 기준으로 페이지네이션 HTML을 다시 렌더링 합니다.

삭제 후 페이지 이동 (21 to 20)

 

아래 이미지는 1번 댓글이 삭제된 후 실행된 drawPage( )의 7번 로직을 디버깅한 결과입니다. 앞에서 drawPage( ) 함수의 7번 로직에서 currentPage를 찾는 두 번째 조건은 댓글을 삭제하는 경우에만 해당된다는 말씀을 드렸는데요. 정확히는 예시와 같이 마지막 페이지에서 댓글이 삭제된 후 같은 페이지에 남은 데이터가 한 개도 없는 경우입니다.

만약, 마지막 페이지에서 댓글이 삭제되었을 때 남아있는 데이터가 1개 이상이라면 기존 페이지 번호가 유지되어야 하는 게 맞지만, 하나뿐인 댓글을 삭제한 후 findAllComment( )가 실행되면 서버에서 내려오는 pagination의 마지막 페이지(totalPageCount)는 20이 됩니다. 쉽게 말해 두 번째 조건이 없다면, 마지막 페이지 번호는 20이지만 파라미터로 넘어온 page(21)를 기준으로 a 태그를 찾게 되는 겁니다.

댓글 삭제 후 drawPage( )의 7번 로직 (크롬 브라우저 디버깅 모드)

 

마치며

여기까지 댓글 CRUD와 Ajax(비동기 방식)를 이용한 페이징 처리가 완료되었습니다. 이제 게시판에서 구현해야 할 기능은 파일 업로드/다운로드와 로그인 정도가 남아있는데요. 게시판의 모든 기능이 완성되면, JPA와 QueryDSL이라는 녀석들을 이용해서 Java 코드와 SQL 쿼리를 객체지향적으로 작성하는 방법을 공유해 볼까 합니다.

다음 글에서는 브라우저의 History API를 이용해서 비동기 페이징 처리의 새로고침 문제를 해결해 볼 건데요. 비동기 통신은 화면의 움직임이 없기 때문에 주소(URL)가 변경되지 않으며, 이로 인해 페이지 새로고침 시 페이지 번호를 유지할 수 없다는 문제가 있습니다. 자세한 내용은 기능을 구현해 나가며 설명드리겠습니다.

오늘도 방문해 주셔서 감사드립니다. 좋은 하루 보내세요 :)

Board.zip
0.88MB

반응형

댓글