FireDrago

[bobzip] AJAX 비동기 요청을 통한 댓글 조회 본문

프로젝트

[bobzip] AJAX 비동기 요청을 통한 댓글 조회

화이용 2024. 7. 2. 15:32

댓글 조회 (클라이언트 코드)

댓글 작성시 페이지를 새로고침 하지 않게 작성해보자 

먼저 댓글 조회 로직을 만들고, 댓글 작성시 댓글 조회 로직을 호출해보자

 

<div class="card mb-4">
    <div class="card-header">
        <h3>댓글</h3>
    </div>
    <div class="card-body">
        <div id="comments-container" th:data-recipe-id="${recipe.id}">

        </div>
        <div id="pagination" class="mt-4 d-flex justify-content-center" th:data-recipe-id="${recipe.id}">

        </div>
        <form method="post" id="comment-form">
            <div class="mb-3">
                <label for="commentText" class="form-label">댓글 입력</label>
                <textarea class="form-control" id="commentText" name="text" rows="3"></textarea>
            </div>
            <button type="submit" class="btn btn-primary">댓글 등록</button>
        </form>
    </div>
</div>

우선 부트스트랩을 활용하여 댓글 탭을 만들었다. 

<div id="comments-container">은 입력한 댓글이 표시되는 태그이고,

<div id="pagination"> 페이지네이션 태그가 생성되는 태그다.

<form method="post" id="comment-form"> 댓글 입력을 위한 입력태그이다.

 

이제 조회 로직을 Jquery 를 이용해서 만들어 보자

$(document).ready(function() {
    const recipeId = $('#comments-container').data('recipe-id');

    loadComments(recipeId);
}

// 향후 페이지네이션을 위해서 recipeId와 page 변수를 받는다.
function loadComments(recipeId, page=0) {
    $.ajax({
        url: `/reply/all/${recipeId}?page=${page}`,
        method: 'GET',
        success: function(response) {
            // 댓글이 들어갈 태그를 모두 비운다.
            $('#comments-container').empty();
            // response 서버에서 받아온 댓글 데이터들을 출력한다.
            response.content.forEach(function(comment) {
                // LocalDateTime 타입을 원하는 형식으로 포맷팅
                const date = new Date(comment.createdTime);
                const formattedDate =  new Intl.DateTimeFormat('ko-KR', {
                    year: 'numeric',
                    month: '2-digit',
                    day: '2-digit',
                    hour: '2-digit',
                    minute: '2-digit'
                }).format(date);
                
                // 댓글 정보를 태그로 만들어서 추가한다.
                $('#comments-container').append(`
                    <div class="p-2 position-relative" id="comment-${comment.id}">
                        <div class="d-flex justify-content-between">
                            <p class="mb-1 fs-5"><strong>${comment.username}</strong></p>
                            <div>
                                <button class="btn btn-sm btn-link" onclick="editComment(${comment.id}, '${comment.comment}')">수정</button>
                                <button class="btn btn-sm btn-link text-danger" onclick="deleteComment(${comment.id})">삭제</button>
                            </div>
                        </div>
                        <p class="text-muted small">${formattedDate}</p>
                        <p class="fs-6" id="comment-text">${comment.comment}</p>
                    </div>
                    <hr>
                `);
            });
            
            // 페이지네이션 업데이트
            const totalPages = response.totalPages;
            const currentPage = response.number;
            generatePagination(totalPages, currentPage, recipeId);
        }
        error: function(error) {
            if (error.response) {
                error.response.data
            }
        }
    });
    
function generatePagination(totalPages, currentPage, recipeId) {
    const $paginationContainer = $('#pagination');
    $paginationContainer.empty();

    // 첫 페이지 링크 생성
    $paginationContainer.append(createPageLink(1, currentPage === 0, recipeId));

    if (totalPages > 1) {
        // 현재 페이지가 5페이지 이상이면 '...' 추가
        if (currentPage > 4) {
            $paginationContainer.append(createEllipsis());
        }
		// 두번째 페이지 ~ 마지막 앞 페이지 까지 생성
        for (let i = Math.max(1, currentPage - 3); i <= Math.min(totalPages - 2, currentPage + 3); i++) {
            $paginationContainer.append(createPageLink(i + 1, currentPage === i, recipeId));
        }
		// 현재페이지가 마지막 페이지보다 5 보다 작으면 '...' 추가
        if (currentPage < totalPages - 5) {
            $paginationContainer.append(createEllipsis());
        }

        // 마지막 페이지 링크 생성
        $paginationContainer.append(createPageLink(totalPages, currentPage === totalPages - 1, recipeId));
    }
}
// 페이지 링크 추가
function createPageLink(pageNum, isCurrent, recipeId) {
    const selectedClass = isCurrent ? 'selected' : '';
    return `
        // 페이지 호출 loadComments 메서드 추가 
        <a class="page-link ${selectedClass}" onclick="loadComments(${recipeId}, ${pageNum - 1})" return false;>
            ${pageNum}
        </a>
    `;
}
$(document).ready(function() {
    // 타임리프에서 전달받은 recipeId를 상수에 할당한다.
    const recipeId = $('#comments-container').data('recipe-id');
    // 댓글을 조회하는 로직을 문서가 호출될때 마다 실행한다. 인자로 recipeId를 넘긴다.
    loadComments(recipeId);
});

$(document).ready()는 jQuery의 편리한 함수로,

자바스크립트에서 document.addEventListener("DOMContentLoaded", function() { });와 동일한 기능을 수행한다. 둘 다 DOM이 완전히 로드되고 파싱된 후에 실행된다.

 

 

AJAX 비동기를 활용하여 서버로부터 댓글정보를 받아오는 loadComments(recipeId, page=0) 메서드를 살펴보자

success: function(response) {
    // 댓글이 들어갈 태그를 모두 비운다.
    $('#comments-container').empty();
    // response 서버에서 받아온 댓글 데이터들을 출력한다.
    response.content.forEach(function(comment) {
        // LocalDateTime 타입을 원하는 형식으로 포맷팅
        const date = new Date(comment.createdTime);
        const formattedDate =  new Intl.DateTimeFormat('ko-KR', {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit',
            hour: '2-digit',
            minute: '2-digit'
        }).format(date);
    }
}

function(response) : 댓글 요청시 서버로부터 전달받은 값이 response 로 할당된다.

response.content.forEact(function(comment) {}) : response 는 스프링의 Page 타입이므로, content로 값을 꺼낸다. 값을 comment로 할당하고, function에서 포맷팅을 진행한다.

 

이제 success: function(response)에 페이지네이션 까지 추가하면 댓글 조회가 완성된다.

페이지네이션에 대한 자세한 설명은 여기서 확인해보자

 

[js] 페이지네이션 만들기

타임리프를 사용하여 레시피를 받아오는 페이지를 만들어 보자!페이지네이션은 많은 양의 데이터를 나누어 표시할 때 유용하게 사용된다.@GetMapping("/all")public String readAllRecipes(@PageableDefault(size =

flowerdragon95.tistory.com

 

댓글조회 (서버 코드)

@RestController
@RequestMapping("/reply")
@RequiredArgsConstructor
public class ReplyController {

    private final ReplyService replyService;

    @GetMapping("/all/{recipeId}")
    public Page<ReplyDto> getRepliesByRecipeId(@PathVariable("recipeId") Long recipeId,
                                               @PageableDefault(size = 10, page = 0, sort = "createdTime") Pageable pageable) {
        Page<Reply> replyPage = replyService.findAll(recipeId, pageable);
        Page<ReplyDto> reply = ReplyDto.toDtoReplyPage(replyPage);
        return reply;
    }
}

AJAX로 요청받은 서버는 recipeId를 @PathVariable로,

@PageDefault 를 이용하여 페이지 정보를 가져온다. @RestController를 사용했기 때문에, 

Page<ReplyDto> 객체를 반환하면 HttpMessageConverter 가 Json 객체로 변환하여 전송한다.

※ 참고

객체를 JSON 형태로 반환할때는 절대로 엔티티 그대로 전송해서는 안된다.

DTO 를 통해 필요한 정보만 담아서 반환할 수 있도록 하자! 

클라이언트에게 엔티티를 노출하는것은 내부 로직을 노출하는것과 같다.

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ReplyService {

    private final ReplyRepository replyRepository;

    public Page<Reply> findAll(Long id, Pageable pageable) {
        return replyRepository.findByRecipeId(id, pageable);
    }
}
public interface ReplyRepository extends JpaRepository<Reply, Long> {

    @Query("select r from Reply r JOIN FETCH r.member where r.recipe.id = :recipeId")
    Page<Reply> findByRecipeId(@Param("recipeId") Long recipeId, Pageable pageable);
}

Reply 객체를 조회할때, Member 객체는 필수적으로 사용되기 때문에,

지연로딩된 Member 객체를 FETCH JOIN을 이용하여 한번에 가져온다. (N+1 문제를 예방한다.)

※ 참고 (N + 1 문제)

지연로딩된 데이터 조회 쿼리가 불필요하게 증가하여 성능이 저하되는 현상

댓글 정보의 작성자 정보가 필요할때마다 댓글, 멤버가 2번씩 조회된다.

예방하기 위해 JOIN FETCH 를 사용한다.