본문 바로가기
프로젝트/Recipository

[Dev] 23.02.27. 댓글 pagination

by 규글 2023. 2. 27.

 Footer를 마지막으로 포트폴리오로 대략적으로 작성할 기본적인 기능을 완성했다고 생각했는데, 역시 또 하나 빠진 것이 있었다. 바로 댓글에 대한 pagination이다. 게시글 목록을 pagination 없이 로딩하는 것과 동일하게, 한 게시글에 작성된 모든 댓글이 한 번에 로딩된다. 필자는 이에 버튼을 눌러 댓글을 몇 개씩만 더 가져올 수 있도록 만들고 싶다. (스크롤로 작업해봤더니 개인적으로 마음에 들지 않았다.)

 

 

작업

content.html

    <div class="comment" id="commentDiv">
        <h2 class="row col-8">댓글</h2>
        <div th:replace="fragments/comment :: comment"></div>
    </div>
    <div class="text-center" th:if="${totalCommentPages > 1}">
        <button class="btn btn-secondary row col-2" id="moreCommentBtn"
                style="margin: 10px;">댓글 더보기</button>
    </div>
    
(중략)------------------------------------------------------------------    
    
<script>
    var contentId = [[${recipe.contentId}]];
    var pageNum = 1;
    var totalCommentPages = [[${totalCommentPages}]];
</script>

  기존에 fragments 에 두 가지 div tag를 만들어놓고 값에 맞게 다른 항목을 rendering 하는 것에서 fragments 그 자체를 가져오도록 바꾸었다. 댓글 data를 더 가져오기 위한 button tag도 추가했다. 중요 포인트는 javascript 에 thymeleaf 를 통한 data를 rendering 하기 위해서는 외부 js file이 아닌 html에 함께 작성해주어야 한다는 점이다. 필자도 외부 js file로 옮겨 작성했더니 syntax error가 발생해서 알게 되었다.

 

    <div class="comment" id="commentDiv">
        <h2 class="row col-8">댓글</h2>
        <div class="row col-8" th:each="comment : ${commentList}">
            <div th:if="${comment.groupId} == ${comment.commentId}">
                <div th:replace="fragments/comment :: normal(${comment})"></div>
            </div>
            <div th:if="${comment.groupId} != ${comment.commentId}">
                <div th:replace="fragments/comment :: reply(${comment})"></div>
            </div>
        </div>
    </div>

 이전에 작업했던 부분이다. Thymeleaf의 if 로 구분하는 것을 fragments 안쪽으로 넣어서 div tag로 구분하지 않고 하나의 fragments로 통합했다. 

 

content.js

    // 댓글 pagination
    if(document.getElementById("moreCommentBtn")){
        document.querySelector("#moreCommentBtn").addEventListener("click", function(){
            if(totalCommentPages > pageNum){
                var url = "/contents/" + contentId + "/comments/" + pageNum;
                var promise = fetch(url);

                promise.then(function(response){
                    return response.text();
                }).then(function(data){
                    var dom = new DOMParser();
                    dom = dom.parseFromString(data, "text/html");
                    var newDiv = dom.getElementsByClassName("comment");

                    var tempArray = Array.from(newDiv);
                    tempArray.forEach(tmp => {
                        document.querySelector("#commentDiv").appendChild(tmp);
                    });

                    pageNum++;

                    if(pageNum == totalCommentPages){
                        document.querySelector("#moreCommentBtn").remove();
                    }
                });
            }
        });
    }

 댓글을 pagination 하여 일부만 로딩하고, 나머지를 추가 로딩하여 가져오기 위한 javascript 이다. 방식은 마이 페이지에서 작업했던 것을 그대로 차용했다.[각주:1] 댓글의 끝까지 읽었는지에 대한 validation을 server의 service method에서 하려고 한다면 무조건적으로 data를 query로 조회해야하기 때문에 client 쪽에서 하도록 if 문 조건을 작성해주었다.

 그때와 다른 점이라고 한다면, 새로운 element에 대한 event listener를 새롭게 추가시켜주어야 한다는 것이다. 이에 대한 문제는 마지막 부분에 추가해두도록 하겠다.

 

PageController.java

    // 댓글 더보기 시 다음 댓글 추가
    @GetMapping("/contents/{contentId}/comments/{pageNum}")
    public String loadComment(Model model,
                              @PathVariable("contentId") Long contentId,
                              @PathVariable("pageNum") Integer pageNum){

        // Map에 삭제한 댓글 id와 삭제 성공 여부를 담아 return
        model.addAttribute("commentList",
                recipeService.getCommentWithPagination(contentId, pageNum));

        return "fragments/comment :: comment";
    }

 댓글 더 보기 버튼을 클릭한 것에 대한 요청을 처리하는 controller의 method이다. 방식은 역시 마이 페이지에서의 방식과 같이 Model 객체에 필요한 data를 담고 fragments를 return 하도록 했다. 특정 게시글의 댓글을 가져올 필요가 있기 때문에 client에 rendering 한 게시글의 id 값을 받아왔고, 또 pagination 한 page의 number를 javascript를 통해 client로부터 받아왔다.

 

RecipeServiceImpl.java

    // 댓글 더보기 시 다음 댓글 추가
    @Override
    public List<CommentDto.CommentResponseDto> getCommentWithPagination(Long contentId, int pageNum) {
        // 게시글에 대한 댓글 data를 가져옴
        List<CommentDto.CommentResponseDto> commentDtoList = new ArrayList<>();

        int pageIndex = pageNum;
        int groupSize = 5;

        PageRequest pageable = PageRequest.of(pageIndex, groupSize);
        Page<Comment> commentList = commentRepository.getCommentWithPagination(contentId, pageable);
        commentList.forEach(tmp -> {
            commentDtoList.add(tmp.toDto());
        });

        return commentDtoList;
    }

 댓글 더 보기 시 조회할 댓글의 List를 가져오기 위한 service method이다. 이는 게시글 pagination의 logic과 동일하다. 조금 차이가 있다면 index 값에 1을 빼지 않았는데, 이는 이미 client에 page number가 1로 되어있기 때문이다. 0으로 시작하면 기존의 것과 다를 것이 없다.

 이렇게 작성하면서 기존에 게시글 data를 return 하는 service method에서 첫 댓글 group만을 return 하도록 변경해주었다.

 

CommentRepository.java

    @Query(value = "select * from comment c " +
            "where c.target_id = :id",
            countQuery = "select count(*) from comment c " +
                    "where c.target_id = :id", nativeQuery = true)
    Page<Comment> getCommentWithPagination(@Param("id") Long id, Pageable pageable);

 댓글 data를 가져오는 method를 새롭게 추가해주었다. 게시글을 조회하고 싶지는 않아서 native query로 작성했다. Count query의 경우는 필자가 작성한 형식대로면 'count(c)' 로 자동 생성되는데, c에 대한 field가 존재하지 않는다는 오류를 발생시켜서 따로 작성해준 것이다. 나머지는 게시글을 pagination 할 때 작성했던 방식과 동일하다.

 

 

기존 문제 해결 (Event Listener to Function)

 문제는 event listener를 추가하는 방식이다. 필자는 이제까지 같은 동작을 하는 element에 대한 event listener를 추가할 때, 각각의 element에 동일한 class를 주고 document.querySelectorAll( ) method를 활용해서 그 모든 element에 대해 event를 추가하는 방식을 사용해왔다. 하지만 이런 방식의 경우 그 같은 동작을 하는 element가 화면이 rendering 된 후에 추가됐을 때, 추가된 element에는 event listener가 적용되지 않은 상태가 되어 새롭게 추가해주어야 하는 작업이 필요하다. 대표적으로 사용자의 마이 페이지에서 화면에 구성된 template을 교체하는 과정에서 그런 방식의 작업이 되어있다. (주석으로 작성한 동일한 게시글에 해당 내용이 있다.)

 

 동작을 이미 작성한 후에는 정상적으로 동작하기는 하니까 크게 문제 없겠지만, 동작을 위한 작업을 하는 중에는 반복적이고 불필요하다고 느껴진다. 그래서 필자는 잘 활용하지 않던 button tag의 onclick 옵션을 활용하고, onclick에 동작할 function을 전달하는 방식을 택했다.

 

comment.html

            <button th:if="${comment.beDeleted} == false"
                    th:data-num="${comment.groupId}"
                    th:data-text="${comment.writer}"
                    th:id = "|reply${comment.commentId}|"
                    sec:authorize="isAuthenticated()"
                    class="btn btn-light float-end col-1 replyBtn" type="submit"
                    onclick="reply(this)"
                    style="width: 70px;">답글</button>

 단순히 onclick 부분이 추가되었을 뿐이다. 'reply' 라는 function이 동작하도록 하고 그 인자로 'this' 를 넘겼는데, 이때 this는 이 button element 자체를 의미한다. 필자의 경우는 id 값을 넘겨서 querySelector 로 받으려고 했는데, 굳이 그럴 필요가 없는 것이다.

 

th:onclick="reply(reply[[${recipe.contentId}]])"

 넘길 때 thymeleaf를 활용해서 위와 같이 넘기기도 했는데, 이 또한 this를 넘긴 것과 동일한 동작을 하는 것을 알게 되었다. 그냥 기록하는 것이다.

 

content.js

    document.querySelectorAll(".replyBtn").forEach(tmp => {
        tmp.addEventListener("click", function(e){
            document.querySelector("#replyIcon").style.display = "block";
            document.querySelector("#groupId").value = this.dataset.num;
            document.querySelector("#comment").focus();
            document.querySelector("#comment").value = "@" + this.dataset.text + " ";
        });
    });

(before)
--------------------------------------------------------------------------------------
(after)

    function reply(btn){
        document.querySelector("#replyIcon").style.display = "block";
        document.querySelector("#groupId").value = btn.dataset.num;
        document.querySelector("#comment").focus();
        document.querySelector("#comment").value = "@" + btn.dataset.text + " ";
    }

 기존에 event listener를 추가하는 방식에서 function을 활용하는 방식으로 변경한 것이다. 클릭한 button element를 전달받도록 했으므로 그에 맞게 약간 변경해주었다.

 

 현 작업과 같은 방식으로 댓글을 삭제하는 버튼과 그에 대한 javascript 역시 동일하게 변경해주었다.

 

 

 이렇게 댓글 pagination에 대한 작업까지 마무리되었다. 이로써 기본적인 게시판으로의 역할을 수행할 수 있게 되었다. 아직 처음 생각했던 것 만큼 작업된 것은 아니지만, 딱 기본 정도의 역할을 수행할 수 있는 이 시점에 간단히 이에 대한 포트폴리오를 만들어두려고 한다. 후에 추가되는 기능과 관련해서는 포트폴리오에 추가하는 방식이 될 것이다.

 

Reference

댓글