본문 바로가기
BackEnd/Project

[Admin] Ch03. 댓글 관리 페이지 구현

by 개발 Blog 2024. 8. 23.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

이번 시간에는 댓글 관리 페이지를 구현한다. 이전에 구현했던 게시글 관리 페이지와 비슷한 흐름으로, 컨트롤러, 서비스, 테스트 코드, 뷰 순서로 진행한다.

 

1. 컨트롤러 구현

이 컨트롤러는 댓글 관리 페이지를 위한 기능을 담당한다.

package org.example.projectboardadmin.controller;

import lombok.RequiredArgsConstructor;
import org.example.projectboardadmin.dto.response.ArticleCommentResponse;
import org.example.projectboardadmin.service.ArticleCommentManagementService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RequestMapping("/management/article-comments")
@Controller
public class ArticleCommentManagementController {

    private final ArticleCommentManagementService articleCommentManagementService;

    @GetMapping
    public String articleComments(Model model){
        model.addAttribute(
                "comments",
                articleCommentManagementService.getArticleComments().stream().map(ArticleCommentResponse::of).toList()
        );

        return "management/article-comments";
    }

    @ResponseBody
    @GetMapping("/{articleCommentId}")
    public ArticleCommentResponse articleComment(@PathVariable Long articleCommentId) {
        return ArticleCommentResponse.of(articleCommentManagementService.getArticleComment(articleCommentId));
    }

    @PostMapping("/{articleCommentId}")
    public String deleteArticleComment(@PathVariable Long articleCommentId) {
        articleCommentManagementService.deleteArticleComment(articleCommentId);

        return "redirect:/management/article-comments";
    }

}

클래스 레벨 설정

  • @RequestMapping("/management/article-comments")를 통해 이 컨트롤러의 기본 경로를 설정했다. 따라서 하위 모든 요청은 /management/article-comments 경로에 맵핑된다.
  • @RequiredArgsConstructor를 사용하여 final 필드에 대한 생성자를 자동으로 만들어, 의존성 주입을 안전하게 처리한다.

articleComments 메서드 (GET)

  • 역할 : 댓글 관리 페이지를 렌더링 하는 역할을 한다.
  • 로직 : getArticleComments()로 댓글 목록을 가져온 후, ArticleCommentResponse로 변환해 모델에 추가한다. 마지막으로 뷰 이름인 "management/article-comments"를 반환해 해당 페이지를 렌더링 한다.

articleComment 메서드 (GET)

  • 역할 : 특정 댓글을 JSON 형태로 반환한다.
  • 특징 : @ResponseBody를 통해 뷰 대신 JSON 응답을 직접 반환하며, @PathVariable로 URL에서 댓글 ID를 받아온다.

deleteArticleComment 메서드 (POST)

  • 역할: 댓글을 삭제하는 기능을 한다.
  • 로직: 댓글 삭제 후, "redirect:/management/article-comments"를 반환해 댓글 목록 페이지로 리다이렉트 한다.

2. 서비스 구현

이 서비스는 댓글 데이터를 외부 API에서 가져오고, 삭제하는 기능을 제공한다.

package org.example.projectboardadmin.service;

import lombok.RequiredArgsConstructor;
import org.example.projectboardadmin.dto.ArticleCommentDto;
import org.example.projectboardadmin.dto.properties.ProjectProperties;
import org.example.projectboardadmin.dto.response.ArticleCommentClientResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;

@RequiredArgsConstructor
@Service
public class ArticleCommentManagementService {

    private final RestTemplate restTemplate;
    private final ProjectProperties projectProperties;

    public List<ArticleCommentDto> getArticleComments() {
        URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articleComments")
                .queryParam("size", 10000) // TODO: 전체 댓글을 가져오기 위해 충분히 큰 사이즈를 전달하는 방식. 불완전하다.
                .build()
                .toUri();
        ArticleCommentClientResponse response = restTemplate.getForObject(uri, ArticleCommentClientResponse.class);

        return Optional.ofNullable(response).orElseGet(ArticleCommentClientResponse::empty).articleComments();
    }

    public ArticleCommentDto getArticleComment(Long articleCommentId) {
        URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articleComments/" + articleCommentId)
                .build()
                .toUri();
        ArticleCommentDto response = restTemplate.getForObject(uri, ArticleCommentDto.class);

        return Optional.ofNullable(response)
                .orElseThrow(() -> new NoSuchElementException("댓글이 없습니다 - articleCommentId: " + articleCommentId));
    }

    public void deleteArticleComment(Long articleCommentId) {
        URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articleComments/" + articleCommentId)
                .build()
                .toUri();
        restTemplate.delete(uri);
    }

}

클래스 레벨 설정

  • @RequiredArgsConstructor로 생성자를 자동으로 생성해 의존성을 주입하고 있다. final로 선언된 RestTemplate과 ProjectProperties가 생성자 주입된다.
  • @Service 애노테이션으로 스프링에서 이 클래스를 서비스로 인식하게 한다.

getArticleComments 메서드

  • 역할: 외부 API에서 댓글 목록을 가져온다.
  • 로직: RestTemplate을 사용해 API 요청을 보낸다. queryParam("size", 10000)를 통해 전체 댓글을 가져오도록 설정했지만, 이 방식은 한계가 있으므로 TODO로 표시해 개선이 필요함을 남겼다.
  • 리턴: 응답이 없을 경우 ArticleCommentClientResponse::empty를 통해 빈 리스트를 반환하여, NPE를 방지한다.

getArticleComment 메서드

  • 역할: 특정 댓글을 가져온다.
  • 로직: 댓글 ID를 이용해 API 요청을 보내고, 해당 댓글이 없을 경우 NoSuchElementException을 발생시킨다.
  • 리턴: Optional로 감싸 오류 처리를 더 명확하게 했다.

deleteArticleComment 메서드

  • 역할: 특정 댓글을 삭제한다.
  • 로직: 댓글 ID를 이용해 DELETE 요청을 보낸다. 단순하지만 중요한 기능을 담당한다.

3. ArticleCommentResponse DTO 작성

댓글 데이터를 응답하기 위한 ArticleCommentResponse DTO를 작성한다. 이 DTO는 ArticleCommentDto에서 필요한 정보를 추출하여 클라이언트에 전달하는 역할을 한다.

package org.example.projectboardadmin.dto.response;


import com.fasterxml.jackson.annotation.JsonInclude;
import org.example.projectboardadmin.dto.ArticleCommentDto;
import org.example.projectboardadmin.dto.UserAccountDto;

import java.time.LocalDateTime;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record ArticleCommentResponse(
        Long id,
        UserAccountDto userAccount,
        String content,
        LocalDateTime createdAt
) {

    public static ArticleCommentResponse of(Long id, UserAccountDto userAccount, String content, LocalDateTime createdAt) {
        return new ArticleCommentResponse(id, userAccount, content, createdAt);
    }

    public static ArticleCommentResponse of(ArticleCommentDto dto) {
        return ArticleCommentResponse.of(dto.id(), dto.userAccount(), dto.content(), dto.createdAt());
    }

}
  • record 키워드를 사용해 클래스를 간결하게 정의했다. Java 14 이상부터 도입된 record는 불변 객체를 쉽게 정의할 수 있으며, DTO의 특성상 적합하다.
  • ArticleCommentDto는 댓글 ID, 관련 게시물 ID, 작성자 정보(UserAccountDto), 댓글 내용, 생성 및 수정 시간과 작성자를 포함한다.
  • of 정적 팩토리 메서드를 통해 DTO를 생성할 수 있게 했다. 이 방식은 생성자 호출보다 가독성이 좋고, 유연하게 추가 로직을 넣을 수 있어 DTO 생성 시 권장된다.
  • 해당 DTO는 댓글 관리에서 필요한 모든 필드를 포함하고 있어, 다양한 상황에서 사용하기 좋다. 데이터 전달 및 뷰 모델 역할을 깔끔하게 수행한다.

4. 테스트 코드 작성

이번 테스트에서는 댓글 관리 기능을 검증하기 위해 WebMvcTest와 MockMvc를 사용했다. 기본적으로 Spring Security를 사용하는 환경이기 때문에 인증된 사용자로 요청을 보내기 위해 @WithMockUser 애노테이션을 추가했다.

package org.example.projectboardadmin.controller;

import org.example.projectboardadmin.config.TestSecurityConfig;
import org.example.projectboardadmin.dto.ArticleCommentDto;
import org.example.projectboardadmin.dto.UserAccountDto;
import org.example.projectboardadmin.service.ArticleCommentManagementService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;
import java.util.List;

import static org.mockito.BDDMockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@DisplayName("컨트롤러 - 댓글 관리")
@Import(TestSecurityConfig.class)
@WebMvcTest(ArticleCommentManagementController.class)
class ArticleCommentManagementControllerTest {

    private final MockMvc mvc;

    @MockBean private ArticleCommentManagementService articleCommentManagementService;

    public ArticleCommentManagementControllerTest(@Autowired MockMvc mvc) {
        this.mvc = mvc;
    }

    @WithMockUser(username = "tester", roles = "USER")
    @DisplayName("[view][GET] 댓글 관리 페이지 - 정상 호출")
    @Test
    void givenNothing_whenRequestingArticleCommentManagementView_thenReturnsArticleCommentManagementView() throws Exception {
        // Given
        given(articleCommentManagementService.getArticleComments()).willReturn(List.of());

        // When & Then
        mvc.perform(get("/management/article-comments"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andExpect(view().name("management/article-comments"))
                .andExpect(model().attribute("comments", List.of()));
        then(articleCommentManagementService).should().getArticleComments();
    }

    @WithMockUser(username = "tester", roles = "USER")
    @DisplayName("[data][GET] 댓글 1개 - 정상 호출")
    @Test
    void givenCommentId_whenRequestingArticleComment_thenReturnsArticleComment() throws Exception {
        // Given
        Long articleCommentId = 1L;
        ArticleCommentDto articleCommentDto = createArticleCommentDto("comment");
        given(articleCommentManagementService.getArticleComment(articleCommentId)).willReturn(articleCommentDto);

        // When & Then
        mvc.perform(get("/management/article-comments/" + articleCommentId))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(articleCommentId))
                .andExpect(jsonPath("$.content").value(articleCommentDto.content()))
                .andExpect(jsonPath("$.userAccount.nickname").value(articleCommentDto.userAccount().nickname()));
        then(articleCommentManagementService).should().getArticleComment(articleCommentId);
    }

    @WithMockUser(username = "tester", roles = "MANAGER")
    @DisplayName("[view][POST] 댓글 삭제 - 정상 호출")
    @Test
    void givenCommentId_whenRequestingDeletion_thenRedirectsToArticleCommentManagementView() throws Exception {
        // Given
        Long articleCommentId = 1L;
        willDoNothing().given(articleCommentManagementService).deleteArticleComment(articleCommentId);

        // When & Then
        mvc.perform(
                        post("/management/article-comments/" + articleCommentId)
                                .with(csrf())
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(view().name("redirect:/management/article-comments"))
                .andExpect(redirectedUrl("/management/article-comments"));
        then(articleCommentManagementService).should().deleteArticleComment(articleCommentId);
    }


    private ArticleCommentDto createArticleCommentDto(String content) {
        return ArticleCommentDto.of(
                1L,
                1L,
                createUserAccountDto(),
                null,
                content,
                LocalDateTime.now(),
                "Eunchan",
                LocalDateTime.now(),
                "Eunchan"
        );
    }

    private UserAccountDto createUserAccountDto() {
        return UserAccountDto.of(
                "ecTest",
                "eunchan-test@email.com",
                "eunchan-test",
                "test memo"
        );
    }
}
  • @WithMockUser는 테스트 시 가짜 사용자로 인증을 처리해 주는 역할을 한다. 이 애노테이션을 통해 스프링 시큐리티 설정을 우회하고, 실제 로그인 없이도 역할과 권한을 테스트할 수 있다.

5. 뷰 구현

댓글 관리 페이지는 HTML과 Thymeleaf XML을 분리하여 디커플드 방식으로 구현되었다. 이 방식은 뷰와 로직을 분리하여 유지보수성을 높이고, 재사용성을 극대화한다.

 

article-comments.html

HTML 파일은 기본적인 레이아웃과 CSS, JavaScript 라이브러리를 포함한다. 페이지의 주요 구조는 이 파일에 정의되어 있지만, 실제 데이터와 UI 로직은 XML 파일에서 주입된다.

        	...
            
            <tr>
                <td>1</td>
                <td><a data-toggle="modal" data-target="#layout-modal">테스트 댓글입니다.</a></td>                <td>Eunchan</td>
                <td><time datetime="2024-01-01T00:00:00">2024-01-01 00:00:00</time></td>
            </tr>
            <tr>
                <td>2</td>
                <td><a data-toggle="modal" data-target="#layout-modal">퍼가요~~</a></td>                <td>Eunchan</td>
                <td><time datetime="2024-01-02T00:00:00">2024-01-02 00:00:00</time></td>
            </tr>
            <tr>
                <td>3</td>
                <td><a data-toggle="modal" data-target="#layout-modal">악성 댓글 XXX</a></td>                <td>Eunchan</td>
                <td><time datetime="2024-01-03T00:00:00">2024-01-03 00:00:00</time></td>
            </tr>
            
            ...
            
    <!-- /.content -->

	...

    <!-- /.modal -->
    <div class="modal fade" id="layout-modal"></div>
</div>

<!--/* REQUIRED SCRIPTS */-->
<script id="layout-scripts">/* 공통 스크립트 삽입부 */</script>

<!--/* 페이지 전용 스크립트 */-->
	...
    ...
    
<script>
    $(document).ready(() => {
        $('#layout-modal').on('show.bs.modal', (event) => {
            const id = $(event.relatedTarget).data('id');

            fetch(`/management/article-comments/${id}`)
                .then(response => response.json())
                .then(data => {
                    $('.modal-title').text('댓글 내용');
                    $('.modal-body pre').text(data.content);
                    $('.modal-footer form').attr('action', `/management/article-comments/${id}`);
                })
                .catch(error => {
                    console.error('댓글 로딩 실패: ', error);
                });
        });
    });
</script>
</body>
</html>

 

article-comments.th.xml

XML 파일은 데이터 바인딩과 UI 로직을 처리하며, Thymeleaf 템플릿 엔진을 통해 동적으로 HTML에 삽입된다.

<?xml version="1.0"?>
<thlogic>
	...
    
    <attr sel="#main-table">
        <attr sel="thead/tr">
            <attr sel="th[0]" th:text="'ID'" />
            <attr sel="th[1]" th:text="'댓글 내용'" />
            <attr sel="th[2]" th:text="'작성자'" />
            <attr sel="th[3]" th:text="'작성일시'" />
        </attr>

        <attr sel="tbody" th:remove="all-but-first">
            <attr sel="tr[0]" th:each="comment : ${comments}">
                <attr sel="td[0]" th:text="${comment.id}" />
                <attr sel="td[1]/a" th:text="${comment.content}" th:href="@{#}" th:data-id="${comment.id}" />
                <attr sel="td[2]" th:text="${comment.userAccount.nickname}" />
                <attr sel="td[3]/time" th:datetime="${comment.createdAt}" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
            </attr>
        </attr>
    </attr>
</thlogic>

이 XML 파일은 댓글 목록을 테이블 형식으로 렌더링 하며, 데이터 바인딩 및 UI 로직을 담당한다.

  • th:each="comment : ${comments}": 댓글 목록을 반복 처리하여 각 댓글 데이터를 테이블 행에 바인딩한다.
  • th:datetime과 #temporals.format: 날짜 데이터를 특정 형식으로 변환하여 출력한다.
layout-main-table-modal.th.xml

이 파일은 댓글 관리 페이지뿐만 아니라 다양한 관리 페이지에서 재사용할 수 있는 모달 레이아웃을 제공하며, 긴 댓글의 내용을 보기 쉽게 처리한다. 주요 기능은 댓글이 길어질 때 자동으로 줄 바꿈을 적용하여 내용이 잘리는 것을 방지하는 것이다.

<?xml version="1.0"?>
<thlogic>
    <attr sel=".modal-body/pre" th:style="'white-space: pre-line'" />

    <!--/* 실제 url 주입은 javascript에 의해 동적으로 이루어져야 한다. th:action은 해당 form에 csrf 토큰을 자동으로 세팅하는데 사용됨. */-->
    <attr sel=".modal-footer/form" th:action="@{#}" th:method="post" />
</thlogic>
  • 이 설정은 긴 텍스트도 모달 창에서 가독성 있게 표시되도록 한다.

5. 실행 화면

1) 댓글 목록 테이블

페이지에 진입하면 모든 댓글이 테이블 형식으로 나열된다. 테이블은 ID, 댓글 내용, 작성자, 작성일시로 구성되며, 이 모든 데이터는 외부 API로부터 불러온다. 테이블 상단에는 검색, 정렬, 복사 등 다양한 기능이 포함되어 있어, 많은 댓글을 관리할 때도 효율적이다.

 

2) 모달 창

댓글 내용을 클릭하면 모달 창이 열리며, 해당 댓글의 자세한 내용을 확인할 수 있다. 길이가 긴 댓글도 자동으로 줄 바꿈이 적용되어 가독성이 좋다. 

 

3) 성능

한 번에 최대 300개의 댓글을 불러와 브라우저에 보관하기 때문에 페이지 이동이나 정렬과 같은 액션은 매우 빠르게 처리된다. 대량의 데이터를 다룰 때도 페이지 반응이 빠른 것이 큰 장점이다.

 

실제 실행 화면은 다음과 같다.

 

이번에 구현한 댓글 관리 페이지는 다양한 기능을 효율적으로 처리할 수 있도록 설계되었다. 디커플드 구조를 통해 뷰와 로직을 분리함으로써 유지보수성과 확장성을 높였고, Spring Security를 사용한 접근 제어 및 테스트 코드 작성도 효율적으로 처리하였다.

 

앞으로 추가적인 기능이 필요할 때도, 이 구조를 활용해 쉽게 확장 가능하다. 댓글 관리 페이지 구현을 통해 전체 시스템의 관리 기능을 안정적이고 효율적으로 구축할 수 있었다.